diff --git a/seo_mode/seo_mode/app/package.json b/seo_mode/seo_mode/app/package.json index 69ff7b3..e59e1b6 100644 --- a/seo_mode/seo_mode/app/package.json +++ b/seo_mode/seo_mode/app/package.json @@ -32,6 +32,8 @@ "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "lucide-react": "^0.561.0", "motion": "^12.27.0", "react": "^19.2.3", diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index 52bfab8..ee2d503 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -24,13 +24,15 @@ import { Search, Settings, Server, + SlidersHorizontal, Trash2, UploadCloud, UserCircle, X } from "lucide-react"; import type { CSSProperties, ChangeEvent, DragEvent, FormEvent } from "react"; -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { FunnelChart as BklitFunnelChart } from "@bklitui/charts/funnel-chart"; import { Grid as BklitGrid } from "@bklitui/charts/grid"; import { HeatmapCells as BklitHeatmapCells } from "@bklitui/charts/heatmap/heatmap-cells"; @@ -134,8 +136,8 @@ const pipeline = [ "Рабочая зона страницы", "Контекст сайта", "Рыночный спрос", - "SEO-план", "Стратегия", + "План правок", "Проверка качества", "Применение" ]; @@ -171,14 +173,14 @@ const stageMeta = [ description: "Проверяем, есть ли внешний спрос по темам сайта, и отделяем подтвержденные фразы от шума." }, { - title: "SEO-план страниц", - sectionTitle: "SEO-план страниц", - description: "Фиксируем, что улучшать на страницах: поле, роль фразы, лимит и причину решения." + title: "Стратегия и карта посадочных", + sectionTitle: "Карта посадочных", + description: "Выбираем кластеры, страницы и правила для следующего плана правок." }, { - title: "Стратегия", - sectionTitle: "SEO-стратегия", - description: "Собираем доказанную карту возможностей, рисков и недостающих внешних данных без правки сайта." + title: "План правок", + sectionTitle: "План правок", + description: "Фиксируем, что улучшать на страницах: поле, роль фразы, лимит и причину решения." }, { title: "Проверка качества", @@ -208,14 +210,14 @@ const stageDecisionFrames: Partial< result: "Подтвержденные фразы, слабые сигналы, шум и недостающие проверки." }, 5: { - gate: "До стратегии нужен зафиксированный план страниц, спроса и evidence.", - question: "Что именно улучшать на страницах и почему?", - result: "Страница, роль фразы, причина, доказательства и ограничения по данным." + gate: "Сайт не меняем: сначала выбираем кластер, посадочную и правила плана.", + question: "Какие кластеры и страницы берём в работу?", + result: "Решение по посадочным, покрываемый спрос, роли фраз, запреты и вход в SEO-план." }, 6: { - gate: "Сайт не меняем: сначала доказываем полезность стратегии.", - question: "Где есть рост, где риск, каких данных не хватает?", - result: "Opportunity map, evidence gaps, риски и варианты следующей SEO-стратегии." + gate: "План правок строится после выбранной посадочной и доказательной базы.", + question: "Что именно улучшать на страницах и почему?", + result: "Страница, роль фразы, причина, доказательства и ограничения по данным." }, 7: { gate: "Применение невозможно без зелёного вердикта проверки.", @@ -245,6 +247,151 @@ function formatCount(value: number | null) { return value === null ? "n/a" : numberFormatter.format(value); } +function getRussianPlural(value: number, one: string, few: string, many: string) { + const normalized = Math.abs(value); + const lastTwo = normalized % 100; + const last = normalized % 10; + + if (lastTwo >= 11 && lastTwo <= 14) { + return many; + } + + if (last === 1) { + return one; + } + + if (last >= 2 && last <= 4) { + return few; + } + + return many; +} + +function formatPhraseCount(value: number) { + return `${formatCount(value)} ${getRussianPlural(value, "фраза", "фразы", "фраз")}`; +} + +function formatPhraseCountGenitive(value: number) { + return `${formatCount(value)} ${getRussianPlural(value, "фразы", "фраз", "фраз")}`; +} + +function formatGroupCount(value: number) { + return `${formatCount(value)} ${getRussianPlural(value, "группа", "группы", "групп")}`; +} + +function formatRuleCount(value: number) { + return `${formatCount(value)} ${getRussianPlural(value, "правило", "правила", "правил")}`; +} + +function formatPercent(value: number) { + return `${Math.round(value * 1000) / 10}%`; +} + +function formatForecastRange(min: number, max: number, suffix = "") { + const minValue = Math.max(0, Math.floor(min)); + const maxValue = Math.max(minValue, Math.ceil(max)); + + if (minValue === maxValue) { + return `~${formatCount(maxValue)}${suffix}`; + } + + return `~${formatCount(minValue)}–${formatCount(maxValue)}${suffix}`; +} + +function formatSignedCount(value: number) { + const roundedValue = Math.round(value); + + if (roundedValue === 0) { + return "без изменения"; + } + + return `${roundedValue > 0 ? "+" : "−"}${formatCount(Math.abs(roundedValue))}`; +} + +function formatSignedForecastRange(min: number, max: number, suffix = "") { + const minValue = Math.round(min); + const maxValue = Math.round(max); + + if (minValue === 0 && maxValue === 0) { + return "без изменения"; + } + + const sign = minValue >= 0 && maxValue >= 0 ? "+" : minValue <= 0 && maxValue <= 0 ? "−" : ""; + const normalizedMin = Math.abs(minValue); + const normalizedMax = Math.abs(maxValue); + + if (normalizedMin === normalizedMax) { + return `${sign}${formatCount(normalizedMax)}${suffix}`; + } + + return `${sign}${formatCount(Math.min(normalizedMin, normalizedMax))}–${formatCount( + Math.max(normalizedMin, normalizedMax) + )}${suffix}`; +} + +function formatLowerFirst(value: string) { + if (!value) { + return value; + } + + return `${value[0]?.toLocaleLowerCase("ru-RU") ?? ""}${value.slice(1)}`; +} + +function getReadablePathProductLabel(targetPath: string | null | undefined) { + const lastSegment = targetPath?.split("/").filter(Boolean).slice(-1)[0]; + + if (!lastSegment) { + return ""; + } + + return lastSegment + .split(/[-_]+/) + .filter(Boolean) + .map((part) => { + const lowerPart = part.toLocaleLowerCase("ru-RU"); + const acronymSegments = new Set(["ai", "api", "b2b", "b2c", "crm", "erp", "hr", "it", "saas", "seo"]); + + if (acronymSegments.has(lowerPart)) { + return part.toLocaleUpperCase("ru-RU"); + } + + return `${part[0]?.toLocaleUpperCase("ru-RU") ?? ""}${part.slice(1).toLocaleLowerCase("ru-RU")}`; + }) + .join(" "); +} + +function formatStrategyDisplayText(value: string) { + return value + .replace(/\bbusiness-process automation\b/gi, "автоматизации бизнес-процессов") + .replace(/\bsupporting term\b/gi, "поддерживающую фразу") + .replace(/\bsupporting opportunity\b/gi, "поддерживающую возможность") + .replace(/support-логика/gi, "поддерживающая логика") + .replace(/выдача intent/gi, "интент выдачи") + .replace(/\bintent\b/gi, "интент") + .replace(/\bexact evidence\b/gi, "точных доказательств") + .replace(/\bpage binding\b/gi, "привязки к странице") + .replace(/\breview lane\b/gi, "очередь ручной проверки") + .replace(/\breview\b/gi, "ручную проверку") + .replace(/\bkeyword decisions\b/gi, "решения по фразам") + .replace(/\bkeyword map\b/gi, "карту фраз") + .replace(/\bSERP\b/g, "выдача") + .replace(/\brewrite\b/gi, "переписывание") + .replace(/\bapply\b/gi, "применение") + .replace(/\bproof\b/gi, "доказательства") + .replace(/\bproduct\/platform angle\b/gi, "продуктовый и платформенный угол") + .replace(/\bbusiness operations angle\b/gi, "операционный угол") + .replace(/\bpage type\b/gi, "тип страницы") + .replace(/\boffer\b/gi, "предложение") + .replace(/оффер/gi, "предложение") + .replace(/выдача интент/gi, "интент выдачи") + .replace(/\bSEO-план\b/g, "план правок") + .replace(/Wordstat\/выдача/g, "Wordstat и выдача") + .replace(/Wordstat и выдача доказательства/g, "доказательства Wordstat и выдачи") + .replace(/интерпретация выдачи/g, "разбор выдачи") + .replace(/доказательства Wordstat и выдачи и разбор выдачи/g, "доказательства Wordstat, выдачи и её разбор") + .replace(/для правки и применение/g, "для правки и применения"); +} + type SeoChartTone = "good" | "neutral" | "problem" | "warning"; type SeoChartDatum = { @@ -278,6 +425,187 @@ type SeoNormalizationSeedView = SeoNormalizationContract["seedStrategy"][number] type KeywordCleaningItemView = KeywordCleaningContract["items"][number]; type KeywordMapItemView = KeywordMapContract["items"][number]; type KeywordPagePlanStatus = "blocked" | "ready" | "review"; +type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen"; +type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support"; +type StrategyKeywordDecisionRole = "article" | "defer" | "differentiator" | "primary" | "secondary" | "support" | "trash"; +type StrategyForecastCtrModel = "base" | "optimistic" | "safe"; +type StrategyForecastRiskMode = "aggressive" | "balanced" | "safe"; +type StrategyDecisionLevel = "high" | "low" | "medium"; +type StrategyForecastSettings = { + conversionRate: number; + ctrModel: StrategyForecastCtrModel; + horizonMonths: number; + riskMode: StrategyForecastRiskMode; +}; +type StrategyKeywordDecisionRow = { + clusterTitle: string; + evidenceRefs: string[]; + frequency: number | null; + id: string; + intent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null; + phrase: string; + reason: string; + role: StrategyKeywordDecisionRole; + targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"] | null; + targetPath: string | null; +}; +type StrategyDecisionScenario = { + businessFit: StrategyDecisionLevel; + cta: string; + demand: number; + effort: StrategyDecisionLevel; + id: string; + isRecommended: boolean; + label: string; + leadsMax: number; + leadsMin: number; + outcome: string; + pages: string[]; + readiness: StrategyDecisionLevel; + risk: StrategyDecisionLevel; + title: string; + trafficMax: number; + trafficMin: number; + visibilityMax: number; + visibilityMin: number; + when: string; + why: string; +}; +type StrategyDecisionScorecardItem = { + detail: string; + label: string; + tone: SeoChartTone; + value: string; +}; +type StrategyDemandLedgerRow = { + detail: string; + label: string; + tone: SeoChartTone; + value: string; +}; +type StrategyLandingPortfolioLane = { + id: string; + label: string; + rows: Array<{ + action: StrategyTargetAction; + demand: number; + pageRole: StrategyPageRole; + reason: string; + status: string; + targetClusters: string[]; + targetPath: string; + }>; +}; +type StrategyDecisionContract = { + conversionRate: number; + deferredDemand: number; + forecastModel: StrategyForecastCtrModel; + horizonMonths: number; + landing: string | null; + nextStage: string; + notPrimaryPhrases: string[]; + primaryCluster: string; + scenarioId: string; + scenarioLabel: string; + selectedDemand: number; + supportClusters: string[]; + visibilityMax: number; + visibilityMin: number; +}; +type StrategyExportFormat = "markdown" | "json" | "pdf"; +type StrategyExportSource = { + detail: string; + id: string; + label: string; +}; +type StrategyExportCard = { + detail?: string; + label: string; + sourceId?: string; + value: string; +}; +type StrategyExportTable = { + columns: string[]; + rows: string[][]; + sourceId?: string; +}; +type StrategyExportSection = { + cards?: StrategyExportCard[]; + sourceId?: string; + summary?: string; + table?: StrategyExportTable; + title: string; +}; +type StrategyExportDomBlock = { + index: number; + kind: "block" | "details" | "section" | "table"; + selector: string; + tables: StrategyExportTable[]; + text: string; + title: string; +}; +type StrategyExportRoute = { + calculation?: string; + element: string; + jsonRoutes: string[]; + value: string | number | null; +}; +type StrategyExportSnapshot = { + decision: { + deferredDemand: number; + forecast: string; + landing: string | null; + nextStage: string; + primaryCluster: string; + scenario: string; + selectedDemand: number; + supportClusters: string[]; + }; + domMirror?: { + blocks: StrategyExportDomBlock[]; + generatedFromSelector: string; + }; + evidenceContracts: { + keywordMap: KeywordMapContract | null; + scanSummary: ProjectScanSummary | null | undefined; + selectedContract: StrategyDecisionContract | null; + selectedScenarioId: string | null; + seoStrategy: SeoStrategyContract | null; + serpInterpretation: SerpInterpretationContract | null; + yandexEvidence: YandexEvidenceContract | null; + }; + generatedAt: string; + project: { + domain?: string; + id: string; + name: string; + pageCount?: number; + }; + raw: { + keywordRows: StrategyKeywordDecisionRow[]; + notPrimaryPhrases: string[]; + opportunityCount: number; + phraseCount: number; + yandexPhraseCount: number; + }; + schemaVersion: "seo-strategy-export.v1"; + sections: StrategyExportSection[]; + sourceRoutes: StrategyExportRoute[]; + sources: StrategyExportSource[]; +}; + +const DEFAULT_STRATEGY_FORECAST_SETTINGS: StrategyForecastSettings = { + conversionRate: 0.01, + ctrModel: "base", + horizonMonths: 3, + riskMode: "balanced" +}; + +const STRATEGY_CTR_RANGES: Record = { + base: { label: "базово", max: 0.05, min: 0.03 }, + optimistic: { label: "оптимистично", max: 0.1, min: 0.05 }, + safe: { label: "осторожно", max: 0.02, min: 0.01 } +}; function clampChartValue(value: number, min: number, max: number) { return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); @@ -410,16 +738,16 @@ function getMarketDecision(market: MarketEnrichmentContract) { if (!market.readiness.ontologyReadyForMarket) { return { - action: "Сначала закрепить project ontology, иначе market evidence будет привязан к неподтверждённому смыслу сайта.", + action: "Сначала закрепить онтологию проекта, иначе рыночные доказательства будут привязаны к неподтверждённому смыслу сайта.", label: "контекст не готов", - summary: "Рыночный спрос пока нельзя считать рабочим evidence.", + summary: "Рыночный спрос пока нельзя считать рабочим доказательством.", tone: "problem" as const }; } if (approvedCount === 0) { return { - action: "Нужен model/human review seed strategy: сейчас нет фраз, которые можно безопасно отправить во внешний рынок.", + action: "Нужна модельная или ручная проверка стартовых фраз: сейчас нет фраз, которые можно безопасно отправить во внешний рынок.", label: "нет очереди", summary: "Market Demand заблокирован до нормализации.", tone: "problem" as const @@ -428,27 +756,27 @@ function getMarketDecision(market: MarketEnrichmentContract) { if (collectedCount === 0) { return { - action: `Запустить Wordstat collection по ${approvedCount} approved фразам, потом решать keyword map.`, + action: `Запустить сбор Wordstat по ${approvedCount} подтверждённым фразам, потом решать карту фраз.`, label: "готово к сбору", - summary: "Контекст уже сужен до approved queue, но внешнего evidence ещё нет.", + summary: "Контекст уже сужен до подтверждённой очереди, но внешних доказательств ещё нет.", tone: "warning" as const }; } if (collectedCount < approvedCount) { return { - action: `Запустить Wordstat collection для оставшихся ${approvedCount - collectedCount} approved фраз, затем решать SEO-план.`, + action: `Запустить сбор Wordstat для оставшихся ${approvedCount - collectedCount} подтверждённых фраз, затем решать SEO-план.`, label: "сбор неполный", - summary: `${collectedCount}/${approvedCount} approved фраз имеют Wordstat evidence.`, + summary: `${collectedCount}/${approvedCount} подтверждённых фраз имеют данные Wordstat.`, tone: "warning" as const }; } if (signalCount === 0) { return { - action: `Не строить SEO-план на текущих фразах. Сначала отдать ${reviewCount} спорных фраз в model/human review и расширить seed strategy.`, + action: `Не строить SEO-план на текущих фразах. Сначала отдать ${reviewCount} спорных фраз в модельную или ручную проверку и расширить стартовую стратегию фраз.`, label: "спрос не доказан", - summary: `${collectedCount} approved фраз проверены, но Wordstat не дал точного или related сигнала.`, + summary: `${collectedCount} подтверждённых фраз проверены, но Wordstat не дал точного или близкого сигнала.`, tone: "problem" as const }; } @@ -456,10 +784,10 @@ function getMarketDecision(market: MarketEnrichmentContract) { return { action: reviewCount > 0 - ? `Двигать в SEO-план только ${signalCount} подтверждённых фраз, остальные ${reviewCount} оставить в review.` - : "Можно передавать подтверждённые фразы в SEO-план и готовить SERP evidence.", + ? `Двигать в SEO-план только ${signalCount} подтверждённых фраз, остальные ${reviewCount} оставить в ручной проверке.` + : "Можно передавать подтверждённые фразы в SEO-план и готовить разбор выдачи.", label: "есть спрос", - summary: `${signalCount}/${approvedCount} approved фраз имеют рыночный сигнал.`, + summary: `${signalCount}/${approvedCount} подтверждённых фраз имеют рыночный сигнал.`, tone: "good" as const }; } @@ -467,13 +795,13 @@ function getMarketDecision(market: MarketEnrichmentContract) { function getMarketEvidenceCards(market: MarketEnrichmentContract): SeoChartDatum[] { return [ { - detail: "approved queue", + detail: "подтверждённая очередь", label: "К проверке", tone: "neutral", value: market.readiness.seedCount }, { - detail: "получили Wordstat row", + detail: "получили строку Wordstat", label: "Проверено", tone: market.wordstat.summary.collectedSeedCount > 0 ? "good" : "warning", value: market.wordstat.summary.collectedSeedCount @@ -751,7 +1079,7 @@ function getKeywordCleaningFunnelData(cleaning: KeywordCleaningContract): SeoCha value: cleaning.readiness.keywordMapCandidateCount }, { - detail: "контентный backlog", + detail: "контентная очередь", label: "Контент", tone: cleaning.readiness.articleCount > 0 ? "warning" : "neutral", value: cleaning.readiness.articleCount @@ -813,7 +1141,7 @@ function getKeywordFunnelData(keywordMap: KeywordMapContract): SeoChartDatum[] { value: keywordMap.readiness.persistableItemCount }, { - detail: "approved decisions", + detail: "подтверждённые решения", label: "Зафиксировано", tone: keywordMap.persisted.approvedDecisionCount > 0 ? "good" : "neutral", value: keywordMap.persisted.approvedDecisionCount @@ -880,7 +1208,7 @@ function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum { .slice(0, 7) .map(([target, cells]) => ({ cells, - detail: target === "target pending" ? "нет page binding" : "page binding", + detail: target === "target pending" ? "нет привязки к странице" : "привязка к странице", key: target, label: target === "target pending" ? "без страницы" : target })); @@ -902,8 +1230,8 @@ function getKeywordRoleFieldLabel(role: KeywordMapItemView["role"]) { const labels = { primary: "title / H1", secondary: "meta description", - support: "body section", - validate: "review lane" + support: "секция страницы", + validate: "ручная проверка" }; return labels[role]; @@ -920,13 +1248,13 @@ function getKeywordPlanDecision(keywordMap: KeywordMapContract) { if (keywordMap.state === "blocked" || !keywordMap.readiness.reviewedOntology) { return { action: - "Сначала закрываем blockers и reviewed ontology. До этого keyword map нельзя считать доказанной базой стратегии.", + "Сначала закрываем блокеры и подтверждаем онтологию. До этого карту фраз нельзя считать доказанной базой стратегии.", label: "нельзя фиксировать", metrics: [ - { detail: "контекст не подтверждён", label: "Ontology", tone: "problem" as SeoChartTone, value: 0 }, - { detail: "page binding", label: "Страниц", tone: "neutral" as SeoChartTone, value: safePageCount }, - { detail: "exact Wordstat", label: "Evidence", tone: "neutral" as SeoChartTone, value: exactEvidenceCount }, - { detail: "ручная очередь", label: "Review", tone: "warning" as SeoChartTone, value: reviewCount } + { detail: "контекст не подтверждён", label: "Контекст", tone: "problem" as SeoChartTone, value: 0 }, + { detail: "привязка к страницам", label: "Страниц", tone: "neutral" as SeoChartTone, value: safePageCount }, + { detail: "точный Wordstat", label: "Данные", tone: "neutral" as SeoChartTone, value: exactEvidenceCount }, + { detail: "ручная очередь", label: "Проверка", tone: "warning" as SeoChartTone, value: reviewCount } ], summary: "Сейчас это диагностический черновик: он показывает, где фразы могли бы лечь, но не имеет права закреплять решения.", @@ -938,18 +1266,18 @@ function getKeywordPlanDecision(keywordMap: KeywordMapContract) { if (keywordMap.readiness.persistableItemCount > 0) { return { action: - "Фиксируем только exact Wordstat + page binding; validate/no-signal остаются вне rewrite и ждут ручной проверки.", + "Фиксируем только точный Wordstat и привязку к странице; спорные фразы и фразы без сигнала не идут в переписывание.", label: "можно фиксировать", metrics: [ { - detail: "approved decisions", + detail: "подтверждённые решения", label: "Фиксировать", tone: "good" as SeoChartTone, value: keywordMap.readiness.persistableItemCount }, { detail: "страницы с безопасными решениями", label: "Страниц", tone: "good" as SeoChartTone, value: safePageCount }, - { detail: "останется в ручной проверке", label: "Review", tone: "warning" as SeoChartTone, value: reviewCount }, - { detail: "без page binding", label: "Без страницы", tone: "warning" as SeoChartTone, value: targetlessCount } + { detail: "останется в ручной проверке", label: "Проверка", tone: "warning" as SeoChartTone, value: reviewCount }, + { detail: "без привязки к странице", label: "Без страницы", tone: "warning" as SeoChartTone, value: targetlessCount } ], summary: `${keywordMap.readiness.persistableItemCount} из ${keywordMap.readiness.seedCount} фраз можно закрепить как безопасные решения. Остальное не должно автоматически попадать в текст.`, title: `Можно закрепить ${keywordMap.readiness.persistableItemCount} SEO-решения`, @@ -959,26 +1287,26 @@ function getKeywordPlanDecision(keywordMap: KeywordMapContract) { return { action: - "Дальше нужно усилить Wordstat/страничные привязки: без exact evidence план остаётся картой проверки, а не заданием на rewrite.", - label: "только review", + "Дальше нужно усилить Wordstat и страничные привязки: без точных доказательств план остаётся картой проверки, а не заданием на переписывание.", + label: "только проверка", metrics: [ { - detail: "exact Wordstat", - label: "Evidence", + detail: "точный Wordstat", + label: "Данные", tone: (exactEvidenceCount > 0 ? "warning" : "problem") as SeoChartTone, value: exactEvidenceCount }, { - detail: "page binding", + detail: "привязка к странице", label: "Назначено", tone: (keywordMap.readiness.mappedItemCount > 0 ? "warning" : "problem") as SeoChartTone, value: keywordMap.readiness.mappedItemCount }, - { detail: "ручная очередь", label: "Review", tone: "warning" as SeoChartTone, value: reviewCount }, - { detail: "без page binding", label: "Без страницы", tone: "warning" as SeoChartTone, value: targetlessCount } + { detail: "ручная очередь", label: "Проверка", tone: "warning" as SeoChartTone, value: reviewCount }, + { detail: "без привязки к странице", label: "Без страницы", tone: "warning" as SeoChartTone, value: targetlessCount } ], summary: - "План уже показывает направления по страницам, но пока не даёт безопасных approved decisions для следующего этапа.", + "План уже показывает направления по страницам, но пока не даёт безопасных подтверждённых решений для следующего этапа.", title: "Фиксация пока не готова", tone: "warning" as SeoChartTone }; @@ -1037,10 +1365,10 @@ function getKeywordPlanPageSummaries(keywordMap: KeywordMapContract) { ? "review" : "blocked"; const statusLabel = - status === "ready" ? "можно фиксировать" : status === "review" ? "ручная проверка" : "не отдавать в rewrite"; + status === "ready" ? "можно фиксировать" : status === "review" ? "ручная проверка" : "не отдавать в переписывание"; const action = status === "ready" - ? `Закрепить ${persistableItems.length} подтверждённые фразы и не тащить review-хвост в rewrite.` + ? `Закрепить ${persistableItems.length} подтверждённые фразы и не тащить хвост ручной проверки в переписывание.` : targetPath === "target pending" ? "Сначала выбрать посадочную или создать страницу, потом пересобрать привязку." : "Проверить формулировки и market evidence перед закреплением."; @@ -1181,7 +1509,7 @@ function getSeoStrategyStateLabel(state: SeoStrategyContract["state"]) { const labels: Record = { blocked: "заблокировано", evidence_gap: "не хватает данных", - ready_for_review: "готово к review", + ready_for_review: "готово к проверке", strategy_draft: "черновик стратегии" }; @@ -1233,10 +1561,10 @@ function getSeoStrategyPriorityLabel(priority: "high" | "low" | "medium") { function getSeoMaturityPhaseLabel(phase: SeoStrategyContract["siteMaturity"]["phase"]) { const labels: Record = { - authority_head_terms: "Authority / head terms", - bootstrap_indexing: "Bootstrap / indexing", - growth: "Growth", - traction: "Traction" + authority_head_terms: "широкие запросы", + bootstrap_indexing: "стартовая индексация", + growth: "рост", + traction: "первые сигналы" }; return labels[phase]; @@ -1265,10 +1593,10 @@ function getSeoOpportunityActionLabel(action: SeoStrategyContract["opportunities function getSeoCompetitionTierLabel(tier: SeoStrategyContract["opportunities"][number]["competitionTier"]) { const labels: Record = { - head: "head", - long_tail: "long-tail", - mid: "mid", - unknown: "unknown" + head: "широкий запрос", + long_tail: "точный низкоконкурентный", + mid: "средняя конкуренция", + unknown: "неясная конкуренция" }; return labels[tier]; @@ -1276,9 +1604,9 @@ function getSeoCompetitionTierLabel(tier: SeoStrategyContract["opportunities"][n function getSeoStrategyOwnerLabel(owner: SeoStrategyContract["missingEvidence"][number]["owner"]) { const labels: Record = { - ai_layer: "AI layer", + ai_layer: "AI-слой", external_service: "внешний сервис", - product_review: "product review" + product_review: "проверка продукта" }; return labels[owner]; @@ -1286,9 +1614,9 @@ function getSeoStrategyOwnerLabel(owner: SeoStrategyContract["missingEvidence"][ function getSeoStrategyRiskLevelLabel(level: SeoStrategyContract["risks"][number]["level"]) { const labels: Record = { - critical: "critical", - high: "high", - medium: "medium" + critical: "критично", + high: "высокий", + medium: "средний" }; return labels[level]; @@ -1306,12 +1634,12 @@ function getStrategySynthesisStateLabel(state: StrategySynthesisContract["state" function getStrategySynthesisScenarioLabel( scenario: StrategySynthesisContract["decisionOptions"][number]["scenario"] ) { - const labels: Record = { - analytics_first: "сначала данные", - bootstrap_runway: "bootstrap runway", + const labels: Record = { + analytics_first: "сначала аналитика", + bootstrap_runway: "стартовая индексация", core_growth: "рост ядра", risk_reduction: "снижение риска", - serp_expansion: "SERP расширение" + serp_expansion: "исследование выдачи" }; return labels[scenario]; @@ -1319,17 +1647,39 @@ function getStrategySynthesisScenarioLabel( function getStrategySynthesisOwnerLabel(owner: StrategySynthesisContract["dataGaps"][number]["owner"]) { const labels: Record = { - ai_layer: "AI layer", + ai_layer: "AI-слой", external_service: "внешний сервис", - product_review: "product review" + product_review: "проверка продукта" }; return labels[owner]; } +function getStrategySynthesisReviewLabel(status: StrategySynthesisContract["analystReview"]["status"]) { + const labels: Record = { + approved: "проверка пройдена", + needs_changes: "нужны правки", + not_reviewed: "не проверено", + rejected: "отклонено" + }; + + return labels[status]; +} + +function getStrategySynthesisReviewClass(status: StrategySynthesisContract["analystReview"]["status"]) { + const classes: Record = { + approved: "collected", + needs_changes: "ready_for_collection", + not_reviewed: "ready_for_collection", + rejected: "not_configured" + }; + + return classes[status]; +} + function getStrategyQualityVerdictLabel(status: StrategyQualityReviewContract["verdict"]["status"]) { const labels: Record = { - approved: "готово к review", + approved: "готово к проверке", blocked: "заблокировано", needs_changes: "нужны правки" }; @@ -1349,9 +1699,9 @@ function getStrategyQualityVerdictClass(status: StrategyQualityReviewContract["v function getStrategyQualityCheckLabel(status: StrategyQualityReviewContract["checks"][number]["status"]) { const labels: Record = { - fail: "fail", - pass: "pass", - warning: "warning" + fail: "ошибка", + pass: "пройдено", + warning: "предупреждение" }; return labels[status]; @@ -1369,12 +1719,12 @@ function getStrategyQualityCheckClass(status: StrategyQualityReviewContract["che function getStrategyQualityCategoryLabel(category: StrategyQualityReviewContract["checks"][number]["category"]) { const labels: Record = { - business_drift: "business drift", - evidence_integrity: "evidence refs", - hallucination_guardrail: "hallucination", - phase_guardrail: "phase runway", - source_safety: "source safety", - ux_clarity: "UX clarity" + business_drift: "бизнесовая рамка", + evidence_integrity: "доказательства", + hallucination_guardrail: "защита от домыслов", + phase_guardrail: "фазовый маршрут", + source_safety: "без правок сайта", + ux_clarity: "понятность" }; return labels[category]; @@ -1393,10 +1743,10 @@ function getYandexEvidenceStateLabel(state: YandexEvidenceContract["state"]) { function getYandexEvidenceIntentLabel(intent: YandexEvidenceContract["phrases"][number]["interpretation"]["serpIntent"]) { const labels: Record = { - commercial: "commercial", - informational: "info", - mixed: "mixed", - unknown: "unknown" + commercial: "коммерческий", + informational: "инфо", + mixed: "смешанный", + unknown: "неясно" }; return labels[intent]; @@ -1404,9 +1754,9 @@ function getYandexEvidenceIntentLabel(intent: YandexEvidenceContract["phrases"][ function getSerpTargetFitLabel(targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"]) { const labels: Record = { - mismatch: "mismatch", - partial_fit: "частичный fit", - strong_fit: "сильный fit", + mismatch: "не совпадает", + partial_fit: "частично подходит", + strong_fit: "подходит", unknown: "неясно" }; @@ -1424,15 +1774,1086 @@ function getSerpRecommendedRoleLabel(role: SerpInterpretationContract["phraseInt return labels[role]; } +function getStrategyKeywordRoleLabel(role: StrategyKeywordDecisionRole) { + const labels: Record = { + article: "в статью", + defer: "отложить", + differentiator: "дифференциатор", + primary: "основная", + secondary: "дополнительная", + support: "поддержка", + trash: "исключить" + }; + + return labels[role]; +} + +function getStrategyKeywordRoleClass(role: StrategyKeywordDecisionRole) { + const classes: Record = { + article: "ready_for_collection", + defer: "not_configured", + differentiator: "partial_ready", + primary: "collected", + secondary: "collected", + support: "ready_for_collection", + trash: "not_configured" + }; + + return classes[role]; +} + +function getStrategyKeywordDecisionRole( + item: KeywordMapContract["items"][number], + serpPhrase: SerpInterpretationContract["phraseInterpretations"][number] | undefined +): StrategyKeywordDecisionRole { + const reason = item.reason.toLowerCase(); + + if (reason.includes("мусор")) { + return "trash"; + } + + if (serpPhrase?.recommendedRole === "article_backlog" || reason.includes("article") || reason.includes("backlog")) { + return "article"; + } + + if (item.role === "primary") return "primary"; + if (item.role === "secondary") return "secondary"; + if (item.role === "support") return "support"; + + return "defer"; +} + +function getStrategyActionLabel(action: StrategyTargetAction) { + const labels: Record = { + create: "создать / связать", + defer: "отложить", + keep: "оставить", + partial: "взять частично", + reject: "не брать", + strengthen: "усилить" + }; + + return labels[action]; +} + +function getStrategyActionClass(action: StrategyTargetAction) { + const classes: Record = { + create: "ready_for_collection", + defer: "not_configured", + keep: "partial_ready", + partial: "partial_ready", + reject: "not_configured", + strengthen: "collected" + }; + + return classes[action]; +} + +function getStrategyRiskLabel(risk: StrategyDecisionScenario["risk"]) { + const labels: Record = { + high: "высокий", + low: "низкий", + medium: "средний" + }; + + return labels[risk]; +} + +function getStrategyEffortLabel(effort: StrategyDecisionScenario["effort"]) { + const labels: Record = { + high: "высокое", + low: "низкое", + medium: "среднее" + }; + + return labels[effort]; +} + +function getStrategyBusinessFitLabel(level: StrategyDecisionLevel) { + const labels: Record = { + high: "высокий", + low: "низкий", + medium: "средний" + }; + + return labels[level]; +} + +function getStrategyReadinessLabel(level: StrategyDecisionLevel) { + const labels: Record = { + high: "высокая", + low: "низкая", + medium: "средняя" + }; + + return labels[level]; +} + +function getStrategyPositiveLevelTone(level: StrategyDecisionLevel): SeoChartTone { + if (level === "high") return "good"; + if (level === "medium") return "warning"; + return "problem"; +} + +function getStrategyRiskTone(level: StrategyDecisionLevel): SeoChartTone { + if (level === "high") return "problem"; + if (level === "medium") return "warning"; + return "good"; +} + +function getStrategyScenarioTone(scenario: StrategyDecisionScenario): SeoChartTone { + if (scenario.risk === "high") return "problem"; + if (scenario.isRecommended) return "good"; + if (scenario.effort === "high") return "warning"; + return "neutral"; +} + +function getStrategyPageRoleLabel(role: StrategyPageRole) { + const labels: Record = { + commercial_landing: "коммерческая посадочная", + docs: "документация", + home: "главная / брендовая", + info_article: "инфо-слой", + support: "поддержка" + }; + + return labels[role]; +} + +function getStrategyIntentDecisionLabel( + intent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null | undefined +) { + if (!intent) { + return "нужно проверить"; + } + + return getYandexEvidenceIntentLabel(intent); +} + +function getStrategyKeywordRoleRank(role: StrategyKeywordDecisionRole) { + const ranks: Record = { + primary: 0, + secondary: 1, + differentiator: 2, + support: 3, + article: 4, + defer: 5, + trash: 6 + }; + + return ranks[role]; +} + +function getStrategyShortReason(row: StrategyKeywordDecisionRow) { + if (row.role === "differentiator") { + return "отличающий слой"; + } + + if (row.role === "primary") { + return "основная ставка"; + } + + if (row.role === "secondary" || row.role === "support") { + return row.targetFit === "strong_fit" ? "подходит посадочной" : "секция посадочной"; + } + + if (row.role === "article") { + return row.intent === "informational" ? "инфо-интент" : "в отдельный материал"; + } + + if (row.role === "defer") { + return "нужна проверка"; + } + + return "исключено"; +} + +function formatStrategyEvidenceRef(ref: string | undefined) { + if (!ref) { + return null; + } + + const phraseMatch = ref.match(/(?:yandex-evidence\.)?phrases:(\d+)/i); + + if (phraseMatch) { + return `подтверждено Wordstat: фраза ${phraseMatch[1]}`; + } + + const decisionMatch = ref.match(/(?:keyword-map\.)?decisions?:(\d+)/i); + + if (decisionMatch) { + return `решение карты фраз: ${decisionMatch[1]}`; + } + + return null; +} + +function getStrategyMostCommonValue(values: T[]) { + const counts = values.reduce>((accumulator, value) => { + accumulator.set(value, (accumulator.get(value) ?? 0) + 1); + return accumulator; + }, new Map()); + + return Array.from(counts.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? null; +} + +function getStrategyKeywordRows( + keywordMap: KeywordMapContract | null, + serpInterpretation: SerpInterpretationContract | null +): StrategyKeywordDecisionRow[] { + const serpByPhrase = new Map((serpInterpretation?.phraseInterpretations ?? []).map((phrase) => [phrase.phrase, phrase])); + + return (keywordMap?.items ?? []) + .map((item) => { + const serpPhrase = serpByPhrase.get(item.phrase); + const role = getStrategyKeywordDecisionRole(item, serpPhrase); + const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason); + + return { + clusterTitle: item.clusterTitle, + evidenceRefs: serpPhrase?.evidenceRefs ?? [], + frequency: item.frequency ?? serpPhrase?.totalDemand ?? null, + id: item.id, + intent: serpPhrase?.intent ?? null, + phrase: item.phrase, + reason, + role, + targetFit: serpPhrase?.targetFit ?? null, + targetPath: item.targetPath ?? serpPhrase?.targetPath ?? null + }; + }) + .filter((row) => row.role !== "trash") + .sort((left, right) => { + const roleDelta = getStrategyKeywordRoleRank(left.role) - getStrategyKeywordRoleRank(right.role); + + if (roleDelta !== 0) { + return roleDelta; + } + + return (right.frequency ?? 0) - (left.frequency ?? 0); + }); +} + +function sumStrategyKeywordDemand(rows: StrategyKeywordDecisionRow[]) { + return rows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); +} + +function getStrategyTopCluster(rows: StrategyKeywordDecisionRow[]) { + const clusterTotals = rows.reduce>((accumulator, row) => { + accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); + + return Array.from(clusterTotals.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? ""; +} + +function getStrategyPrimaryTargetPath( + keywordRows: StrategyKeywordDecisionRow[], + seoStrategy: SeoStrategyContract | null +) { + const usableRoles = new Set(["primary", "secondary", "support"]); + const targetTotals = keywordRows.reduce>((accumulator, row) => { + if (!row.targetPath || !usableRoles.has(row.role)) { + return accumulator; + } + + accumulator.set(row.targetPath, (accumulator.get(row.targetPath) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); + + const keywordTarget = Array.from(targetTotals.entries()).sort((left, right) => right[1] - left[1])[0]?.[0]; + + return keywordTarget ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? null; +} + +function sanitizeExportFilename(value: string) { + const sanitized = value + .trim() + .toLocaleLowerCase("ru-RU") + .replace(/[^a-zа-я0-9]+/gi, "-") + .replace(/^-+|-+$/g, ""); + + return sanitized || "seo-strategy"; +} + +function escapeMarkdownTableCell(value: string) { + return value.replace(/\|/g, "\\|").replace(/\n/g, " "); +} + +function downloadClientFile(filename: string, blob: Blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +function buildStrategyExportSnapshot(input: { + keywordMap: KeywordMapContract | null; + project: ProjectSummary; + scanSummary: ProjectScanSummary | null | undefined; + selectedContract: StrategyDecisionContract | null; + selectedScenarioId: string | null; + seoStrategy: SeoStrategyContract | null; + serpInterpretation: SerpInterpretationContract | null; + yandexEvidence: YandexEvidenceContract | null; +}): StrategyExportSnapshot { + const keywordRows = getStrategyKeywordRows(input.keywordMap, input.serpInterpretation); + const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows, input.seoStrategy); + const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows; + const decisionRows = rowsForLanding.filter((row) => { + return ( + (row.role === "primary" || row.role === "secondary" || row.role === "support") && + row.intent !== "informational" && + row.targetFit !== "mismatch" + ); + }); + const notPrimaryRows = keywordRows.filter((row) => { + return ( + row.role === "article" || + row.role === "defer" || + row.intent === "informational" || + row.targetFit === "mismatch" || + !row.targetPath + ); + }); + const selectedDemand = input.selectedContract?.selectedDemand ?? sumStrategyKeywordDemand(decisionRows); + const totalDemand = + input.yandexEvidence?.summary.totalDemand ?? + input.serpInterpretation?.yandexEvidence.totalDemand ?? + input.seoStrategy?.evidence.demand.totalFrequency ?? + sumStrategyKeywordDemand(keywordRows); + const deferredDemand = input.selectedContract?.deferredDemand ?? Math.max(0, totalDemand - selectedDemand); + const supportClusters = + input.selectedContract?.supportClusters ?? + Array.from(new Set(decisionRows.map((row) => row.clusterTitle).filter(Boolean))).slice(1, 4); + const primaryCluster = + input.selectedContract?.primaryCluster || + getStrategyTopCluster(decisionRows) || + input.seoStrategy?.opportunities[0]?.title || + "Кластер не выбран"; + const landing = input.selectedContract?.landing ?? primaryTargetPath; + const visibilityMin = input.selectedContract?.visibilityMin ?? selectedDemand * 0.03; + const visibilityMax = input.selectedContract?.visibilityMax ?? selectedDemand * 0.05; + const conversionRate = input.selectedContract?.conversionRate ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.conversionRate; + const horizonMonths = input.selectedContract?.horizonMonths ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.horizonMonths; + const forecast = `${formatForecastRange(visibilityMin, visibilityMax, " визитов/мес.")}, ${formatForecastRange( + visibilityMin * conversionRate, + visibilityMax * conversionRate, + " лидов/мес." + )}`; + const scenario = input.selectedContract?.scenarioLabel ?? input.selectedScenarioId ?? "Рекомендованный сценарий"; + const notPrimaryPhrases = + input.selectedContract?.notPrimaryPhrases ?? + notPrimaryRows + .map((row) => row.phrase) + .filter(Boolean) + .slice(0, 12); + const productLabel = getReadablePathProductLabel(landing); + const siteTitle = getProjectSiteTitle(input.scanSummary); + const brandNameCandidate = siteTitle.split(/\s+[—-]\s+|\s+\|\s+/)[0]?.trim(); + const brandName = + brandNameCandidate && !brandNameCandidate.toLocaleLowerCase("ru-RU").includes("тайтл") + ? brandNameCandidate + : input.project.name; + const titleSubject = + primaryCluster && primaryCluster !== "Кластер не выбран" ? formatLowerFirst(primaryCluster) : formatLowerFirst(productLabel); + const titleProduct = [brandName, productLabel].filter(Boolean).join(" "); + const titleDraft = titleSubject ? `${titleProduct} — ${titleSubject}` : titleProduct; + const metaSubject = titleSubject || formatLowerFirst(productLabel) || "выбранную посадочную"; + const metaDraft = `Посадочная по теме «${metaSubject}»: что входит, для кого подходит и какие задачи закрывает.`; + const phraseTableRows = keywordRows.slice(0, 40).map((row) => { + const evidenceRefs = row.evidenceRefs + .map(formatStrategyEvidenceRef) + .filter((ref): ref is string => Boolean(ref)); + + return [ + row.phrase, + formatCount(row.frequency), + row.clusterTitle, + getStrategyKeywordRoleLabel(row.role), + row.targetPath ?? "URL не выбран", + evidenceRefs.join("; ") || "связь из карты фраз" + ]; + }); + const sources: StrategyExportSource[] = [ + { + detail: `${input.keywordMap?.items.length ?? 0} фраз и решений по целевым страницам.`, + id: "keyword-map.items", + label: "Карта фраз" + }, + { + detail: `${input.serpInterpretation?.readiness.interpretedPhraseCount ?? 0} интерпретаций интента и пригодности посадочной.`, + id: "serp-interpretation.phraseInterpretations", + label: "SERP-разбор" + }, + { + detail: `${input.yandexEvidence?.summary.checkedPhraseCount ?? 0} проверенных фраз, суммарный спрос ${formatCount( + input.yandexEvidence?.summary.totalDemand ?? null + )}.`, + id: "yandex-evidence.summary", + label: "Wordstat / внешние доказательства" + }, + { + detail: `${input.seoStrategy?.opportunities.length ?? 0} возможностей, ${input.seoStrategy?.risks.length ?? 0} рисков.`, + id: "seo-strategy.opportunities", + label: "SEO-стратегия" + }, + { + detail: `${input.scanSummary?.counts.pages ?? 0} страниц и ${input.scanSummary?.counts.assets ?? 0} ассетов в локальном скане.`, + id: "project-scan.summary", + label: "Скан проекта" + } + ]; + const sections: StrategyExportSection[] = [ + { + cards: [ + { + detail: `Берём ${formatCount(selectedDemand)} из ${formatCount(totalDemand)} спроса. Не берём сейчас ${formatCount( + deferredDemand + )}: широкий спрос, статьи, спорный интент или ручная проверка.`, + label: "Решение", + sourceId: "keyword-map.items", + value: `${landing ?? "Посадочная не выбрана"} закрывает «${primaryCluster}»` + }, + { + detail: `${formatPercent(conversionRate)} конверсии, горизонт ${horizonMonths} мес.`, + label: "Сценарный прогноз", + sourceId: "yandex-evidence.summary", + value: forecast + }, + { + detail: supportClusters.length ? supportClusters.join(", ") : "Поддерживающие кластеры не выделены.", + label: "Поддерживающие кластеры", + sourceId: "keyword-map.items", + value: formatGroupCount(supportClusters.length) + } + ], + sourceId: "keyword-map.items", + summary: "Короткий вариант того, что именно берём в работу и почему это не превращается в общий список фраз.", + title: "Рекомендованное решение" + }, + { + cards: [ + { + detail: "Это вход для следующего этапа, сайт на этом шаге не меняется.", + label: "Следующий этап", + sourceId: "seo-strategy.opportunities", + value: input.selectedContract?.nextStage ?? "План правок" + }, + { + detail: notPrimaryPhrases.length ? notPrimaryPhrases.join(", ") : "Нет явных отложенных фраз.", + label: "Отложено", + sourceId: "serp-interpretation.phraseInterpretations", + value: formatPhraseCount(notPrimaryPhrases.length) + }, + { + detail: `${formatPhraseCount(keywordRows.length)} попали в экспортную таблицу, максимум 40 строк для отчёта.`, + label: "Фразы в разборе", + sourceId: "keyword-map.items", + value: formatPhraseCount(keywordRows.length) + } + ], + sourceId: "seo-strategy.opportunities", + summary: "Контракт решения нужен, чтобы план правок не спорил заново о посадочной, спросе и исключениях.", + title: "Контракт для плана правок" + }, + { + cards: [ + { + detail: `Черновая база: ${titleDraft}`, + label: "SEO Title", + sourceId: "keyword-map.items", + value: titleDraft + }, + { + detail: metaDraft, + label: "Описание страницы", + sourceId: "keyword-map.items", + value: metaDraft + }, + { + detail: input.seoStrategy?.verdict.primaryConstraint ?? "Ограничение не собрано.", + label: "Ограничение", + sourceId: "seo-strategy.opportunities", + value: input.seoStrategy?.verdict.title ?? "Стратегия не собрана" + } + ], + sourceId: "seo-strategy.opportunities", + summary: "Черновики не применяются к сайту автоматически. Это ориентир для будущего редакционного плана.", + title: "Каркас посадочной" + }, + { + sourceId: "keyword-map.items", + summary: "Таблица показывает не только фразу, но и роль, URL и доказательство, откуда взято решение.", + table: { + columns: ["Фраза", "Спрос", "Кластер", "Роль", "URL", "Источник решения"], + rows: phraseTableRows, + sourceId: "keyword-map.items" + }, + title: "Фразы и доказательства" + } + ]; + const sourceRoutes: StrategyExportRoute[] = [ + { + calculation: "Самый сильный targetPath среди пригодных primary/secondary/support строк карты фраз.", + element: "decision.landing", + jsonRoutes: ["keywordMap.items[].targetPath", "serpInterpretation.phraseInterpretations[].targetPath"], + value: landing + }, + { + calculation: "Сумма frequency по строкам, которые идут в выбранную посадочную и не являются информационным/спорным интентом.", + element: "decision.selectedDemand", + jsonRoutes: ["keywordMap.items[].frequency", "serpInterpretation.phraseInterpretations[].intent", "keywordMap.items[].targetPath"], + value: selectedDemand + }, + { + calculation: "Проверенный спрос минус спрос, выбранный в текущий план.", + element: "decision.deferredDemand", + jsonRoutes: ["yandexEvidence.summary.totalDemand", "seoStrategy.evidence.demand.totalFrequency", "decision.selectedDemand"], + value: deferredDemand + }, + { + calculation: "Доминирующий кластер по суммарной частотности строк выбранной посадочной.", + element: "decision.primaryCluster", + jsonRoutes: ["keywordMap.items[].clusterTitle", "keywordMap.items[].frequency", "seoStrategy.opportunities[].title"], + value: primaryCluster + }, + { + calculation: "Сценарный диапазон: selectedDemand * CTR visibility range; лиды: traffic * conversionRate.", + element: "decision.forecast", + jsonRoutes: ["decision.selectedDemand", "strategyForecastSettingsByProjectId", "selectedStrategyScenarioIds"], + value: forecast + } + ]; + + return { + decision: { + deferredDemand, + forecast, + landing, + nextStage: input.selectedContract?.nextStage ?? "План правок", + primaryCluster, + scenario, + selectedDemand, + supportClusters + }, + evidenceContracts: { + keywordMap: input.keywordMap, + scanSummary: input.scanSummary, + selectedContract: input.selectedContract, + selectedScenarioId: input.selectedScenarioId, + seoStrategy: input.seoStrategy, + serpInterpretation: input.serpInterpretation, + yandexEvidence: input.yandexEvidence + }, + generatedAt: new Date().toISOString(), + project: { + id: input.project.id, + name: input.project.name, + pageCount: input.scanSummary?.counts.pages + }, + raw: { + keywordRows, + notPrimaryPhrases, + opportunityCount: input.seoStrategy?.opportunities.length ?? 0, + phraseCount: keywordRows.length, + yandexPhraseCount: input.yandexEvidence?.summary.checkedPhraseCount ?? 0 + }, + schemaVersion: "seo-strategy-export.v1", + sections, + sourceRoutes, + sources + }; +} + +function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) { + if (snapshot.domMirror?.blocks.length) { + const lines = [ + `# Стратегия и карта посадочных`, + "", + `Проект: **${snapshot.project.name}**`, + `Сформировано: ${formatDate(snapshot.generatedAt)}`, + `Схема: \`${snapshot.schemaVersion}\``, + "", + "## Источники и маршруты данных", + ...snapshot.sources.map((source) => `- **${source.label}** (\`${source.id}\`): ${source.detail}`), + "", + ...snapshot.sourceRoutes.map((route) => { + const routes = route.jsonRoutes.map((jsonRoute) => `\`${jsonRoute}\``).join(", "); + return `- **${route.element}:** ${String(route.value ?? "n/a")} · ${route.calculation ?? "расчёт не указан"} · ${routes}`; + }), + "" + ]; + + for (const block of snapshot.domMirror.blocks) { + lines.push(`## ${block.title || `Блок ${block.index + 1}`}`); + lines.push(``); + + if (block.text) { + lines.push(""); + lines.push(block.text); + } + + for (const table of block.tables) { + lines.push(""); + lines.push(`| ${table.columns.map(escapeMarkdownTableCell).join(" | ")} |`); + lines.push(`| ${table.columns.map(() => "---").join(" | ")} |`); + + for (const row of table.rows) { + lines.push(`| ${row.map(escapeMarkdownTableCell).join(" | ")} |`); + } + } + + lines.push(""); + } + + lines.push("## Полный технический JSON"); + lines.push(""); + lines.push("Полная доказательная база, исходные контракты и расчётные маршруты находятся в JSON-экспорте с той же схемой `seo-strategy-export.v1`."); + + return `${lines.join("\n").trim()}\n`; + } + + const lines = [ + `# Стратегия и карта посадочных`, + "", + `Проект: **${snapshot.project.name}**`, + `Сформировано: ${formatDate(snapshot.generatedAt)}`, + `Схема: \`${snapshot.schemaVersion}\``, + "", + "## Источники", + ...snapshot.sources.map((source) => `- **${source.label}** (\`${source.id}\`): ${source.detail}`), + "" + ]; + + for (const section of snapshot.sections) { + lines.push(`## ${section.title}`); + + if (section.summary) { + lines.push(section.summary); + } + + if (section.cards?.length) { + lines.push(""); + + for (const card of section.cards) { + lines.push(`- **${card.label}:** ${card.value}`); + + if (card.detail) { + lines.push(` ${card.detail}`); + } + + if (card.sourceId) { + lines.push(` Источник: \`${card.sourceId}\``); + } + } + } + + if (section.table) { + lines.push(""); + lines.push(`| ${section.table.columns.map(escapeMarkdownTableCell).join(" | ")} |`); + lines.push(`| ${section.table.columns.map(() => "---").join(" | ")} |`); + + for (const row of section.table.rows) { + lines.push(`| ${row.map(escapeMarkdownTableCell).join(" | ")} |`); + } + } + + lines.push(""); + } + + return `${lines.join("\n").trim()}\n`; +} + +function waitForDomPaint() { + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => resolve()); + }); + }); +} + +function getStrategyExportRoot() { + return document.querySelector(".workspace.stage-6 .market-panel"); +} + +function getElementSelectorLabel(element: Element) { + const className = Array.from(element.classList).join("."); + + if (element.id) { + return `#${element.id}`; + } + + return `${element.tagName.toLocaleLowerCase("en-US")}${className ? `.${className}` : ""}`; +} + +function normalizeExportText(value: string | null | undefined) { + return (value ?? "").replace(/\s+/g, " ").trim(); +} + +const strategyExportTextBlockTags = new Set([ + "ARTICLE", + "ASIDE", + "BUTTON", + "DD", + "DETAILS", + "DIV", + "DL", + "DT", + "FIGCAPTION", + "FOOTER", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADER", + "LI", + "OL", + "P", + "SECTION", + "SUMMARY", + "UL" +]); + +function normalizeStrategyExportLines(lines: string[]) { + const normalizedLines: string[] = []; + + for (const line of lines) { + const normalizedLine = normalizeExportText(line); + + if (!normalizedLine) { + continue; + } + + if (normalizedLines[normalizedLines.length - 1] === normalizedLine) { + continue; + } + + normalizedLines.push(normalizedLine); + } + + return normalizedLines; +} + +function elementToStrategyExportText(element: Element) { + const lines: string[] = []; + let currentLine = ""; + + const appendText = (value: string | null | undefined) => { + const text = normalizeExportText(value); + + if (!text) { + return; + } + + currentLine = currentLine ? `${currentLine} ${text}` : text; + }; + + const flushLine = () => { + const line = normalizeExportText(currentLine); + + if (line) { + lines.push(line); + } + + currentLine = ""; + }; + + const walk = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + appendText(node.textContent); + return; + } + + if (!(node instanceof Element)) { + return; + } + + if (node.matches("script, style, svg, canvas, table")) { + return; + } + + if (node.tagName === "BR") { + flushLine(); + return; + } + + const isBlock = strategyExportTextBlockTags.has(node.tagName); + + if (isBlock) { + flushLine(); + } + + node.childNodes.forEach(walk); + + if (isBlock) { + flushLine(); + } + }; + + walk(element); + flushLine(); + + return normalizeStrategyExportLines(lines).join("\n"); +} + +function tableToStrategyExportTable(table: HTMLTableElement): StrategyExportTable { + const columns = Array.from(table.querySelectorAll("thead th")).map((cell) => normalizeExportText(cell.textContent)); + const fallbackColumnCount = table.querySelector("tr")?.children.length ?? 0; + const normalizedColumns = + columns.length > 0 ? columns : Array.from({ length: fallbackColumnCount }, (_, index) => `Колонка ${index + 1}`); + const rows = Array.from(table.querySelectorAll("tbody tr")).map((row) => + Array.from(row.children).map((cell) => normalizeExportText(cell.textContent)) + ); + + return { + columns: normalizedColumns, + rows + }; +} + +function getStrategyExportBlockTitle(element: Element, index: number) { + const title = + element.querySelector(".strategy-section-heading strong")?.textContent ?? + element.querySelector("summary strong")?.textContent ?? + element.querySelector(".market-heading strong")?.textContent ?? + element.querySelector(".strategy-decision-main strong")?.textContent ?? + element.querySelector("strong")?.textContent; + + return normalizeExportText(title) || `Блок ${index + 1}`; +} + +function collectStrategyDomMirror(): StrategyExportSnapshot["domMirror"] { + const root = getStrategyExportRoot(); + + if (!root) { + return undefined; + } + + const blocks: Element[] = []; + const heading = root.querySelector(":scope > .market-heading"); + const cockpit = root.querySelector(":scope > .strategy-cockpit"); + const evidence = root.querySelector(":scope > .strategy-dev-details"); + + if (heading) { + blocks.push(heading); + } + + if (cockpit) { + blocks.push(...Array.from(cockpit.children)); + } + + if (evidence) { + blocks.push(evidence); + } + + return { + blocks: blocks.map((block, index) => { + const tables = Array.from(block.querySelectorAll("table")).map(tableToStrategyExportTable); + const blockClone = block.cloneNode(true) as HTMLElement; + + blockClone.querySelectorAll("table").forEach((table) => table.remove()); + + return { + index, + kind: block.matches("details") ? "details" : block.matches("section") ? "section" : tables.length > 0 ? "table" : "block", + selector: getElementSelectorLabel(block), + tables, + text: elementToStrategyExportText(blockClone), + title: getStrategyExportBlockTitle(block, index) + }; + }), + generatedFromSelector: ".workspace.stage-6 .market-panel" + }; +} + +function openStrategyDetails(root: HTMLElement) { + root.querySelectorAll("details").forEach((details) => { + details.setAttribute("open", ""); + }); +} + +function getNodedcPdfLogoSvg() { + return ``; +} + +function createStrategyPdfExportBlocks(root: HTMLElement) { + const blocks: HTMLElement[] = []; + const heading = root.querySelector(":scope > .market-heading"); + const cockpit = root.querySelector(":scope > .strategy-cockpit"); + const evidence = root.querySelector(":scope > .strategy-dev-details"); + + if (heading) { + blocks.push(heading); + } + + if (cockpit) { + blocks.push(...Array.from(cockpit.children).filter((child): child is HTMLElement => child instanceof HTMLElement)); + } + + if (evidence) { + blocks.push(evidence); + } + + return blocks; +} + +function createStrategyPdfRenderRoot(project: ProjectSummary) { + const sourceRoot = getStrategyExportRoot(); + + if (!sourceRoot) { + throw new Error("Не удалось найти DOM стратегии для PDF."); + } + + const sourceWidth = Math.max(960, Math.ceil(sourceRoot.getBoundingClientRect().width)); + const renderRoot = document.createElement("div"); + const clonedRoot = sourceRoot.cloneNode(true) as HTMLElement; + const title = document.createElement("div"); + + openStrategyDetails(clonedRoot); + clonedRoot.querySelectorAll(".strategy-decision-confirm-backdrop").forEach((node) => node.remove()); + + renderRoot.className = "seo-strategy-pdf-render-root"; + renderRoot.style.width = `${sourceWidth}px`; + title.className = "seo-strategy-pdf-title"; + title.innerHTML = getNodedcPdfLogoSvg(); + + const titleCopy = document.createElement("div"); + const titleHeading = document.createElement("strong"); + const titleProject = document.createElement("span"); + + titleHeading.textContent = "Стратегия и карта посадочных"; + titleProject.textContent = project.name; + titleCopy.append(titleHeading, titleProject); + title.append(titleCopy); + renderRoot.append(title, clonedRoot); + document.body.append(renderRoot); + + return { + blocks: [title, ...createStrategyPdfExportBlocks(clonedRoot)], + cleanup: () => renderRoot.remove(), + width: sourceWidth + }; +} + +function createCanvasSlice(sourceCanvas: HTMLCanvasElement, y: number, height: number) { + const sliceCanvas = document.createElement("canvas"); + const sliceHeight = Math.min(height, sourceCanvas.height - y); + const context = sliceCanvas.getContext("2d"); + + sliceCanvas.width = sourceCanvas.width; + sliceCanvas.height = sliceHeight; + + if (!context) { + throw new Error("Не удалось подготовить страницу PDF."); + } + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height); + context.drawImage(sourceCanvas, 0, y, sourceCanvas.width, sliceHeight, 0, 0, sourceCanvas.width, sliceHeight); + + return sliceCanvas; +} + +async function downloadStrategyPdfMirror(project: ProjectSummary, filename: string) { + const [{ default: html2canvas }, { jsPDF }] = await Promise.all([import("html2canvas"), import("jspdf")]); + const renderRoot = createStrategyPdfRenderRoot(project); + + try { + await waitForDomPaint(); + + const pdf = new jsPDF({ format: "a4", orientation: "portrait", unit: "mm" }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 8; + const contentWidth = pageWidth - margin * 2; + const contentHeight = pageHeight - margin * 2; + let cursorY = margin; + let hasContent = false; + + for (const block of renderRoot.blocks) { + const canvas = await html2canvas(block, { + backgroundColor: "#ffffff", + scale: Math.min(1.5, window.devicePixelRatio || 1.25), + useCORS: true, + width: renderRoot.width, + windowWidth: renderRoot.width + }); + const imageHeight = (canvas.height * contentWidth) / canvas.width; + + if (imageHeight <= contentHeight) { + if (hasContent && cursorY + imageHeight > pageHeight - margin) { + pdf.addPage(); + cursorY = margin; + } + + pdf.addImage(canvas.toDataURL("image/png"), "PNG", margin, cursorY, contentWidth, imageHeight, undefined, "FAST"); + cursorY += imageHeight + 3; + hasContent = true; + continue; + } + + if (hasContent) { + pdf.addPage(); + cursorY = margin; + } + + const sliceHeightPx = Math.floor((contentHeight * canvas.width) / contentWidth); + + for (let y = 0; y < canvas.height; y += sliceHeightPx) { + if (y > 0) { + pdf.addPage(); + } + + const sliceCanvas = createCanvasSlice(canvas, y, sliceHeightPx); + const sliceImageHeight = (sliceCanvas.height * contentWidth) / sliceCanvas.width; + + pdf.addImage(sliceCanvas.toDataURL("image/png"), "PNG", margin, margin, contentWidth, sliceImageHeight, undefined, "FAST"); + } + + cursorY = margin; + hasContent = true; + pdf.addPage(); + } + + if (!hasContent) { + throw new Error("PDF не собран: нет блоков стратегии."); + } + + if (cursorY === margin && pdf.getNumberOfPages() > 1) { + pdf.deletePage(pdf.getNumberOfPages()); + } + + downloadClientFile(filename, pdf.output("blob")); + } finally { + renderRoot.cleanup(); + } +} + function getYandexEvidenceSeasonalityLabel( seasonality: YandexEvidenceContract["phrases"][number]["interpretation"]["seasonality"] ) { const labels: Record = { - falling: "falling", + falling: "падает", insufficient: "нет данных", - rising: "rising", - stable: "stable", - volatile: "volatile" + rising: "растёт", + stable: "стабильно", + volatile: "скачет" }; return labels[seasonality]; @@ -1441,8 +2862,8 @@ function getYandexEvidenceSeasonalityLabel( function getSeoStrategyReadinessBars(strategy: SeoStrategyContract): SeoChartDatum[] { return [ { - detail: "strategy confidence", - label: "Score", + detail: "доверие к стратегии", + label: "Оценка", tone: strategy.readiness.strategyConfidenceScore >= 75 ? "good" @@ -1452,26 +2873,26 @@ function getSeoStrategyReadinessBars(strategy: SeoStrategyContract): SeoChartDat value: strategy.readiness.strategyConfidenceScore }, { - detail: "approved keyword decisions", + detail: "подтверждённые решения по фразам", label: "Решения", tone: strategy.readiness.approvedKeywordCount > 0 ? "good" : "problem", value: strategy.readiness.approvedKeywordCount }, { - detail: "external evidence", - label: "Evidence", + detail: "внешние доказательства", + label: "Данные", tone: strategy.readiness.marketEvidenceCount > 0 ? "good" : "warning", value: strategy.readiness.marketEvidenceCount }, { - detail: "opportunity groups", + detail: "группы возможностей", label: "Возможности", tone: strategy.readiness.opportunityCount > 0 ? "good" : "warning", value: strategy.readiness.opportunityCount }, { detail: "нужно закрыть", - label: "Gaps", + label: "Пробелы", tone: strategy.readiness.evidenceGapCount > 0 ? "warning" : "good", value: strategy.readiness.evidenceGapCount } @@ -1481,26 +2902,26 @@ function getSeoStrategyReadinessBars(strategy: SeoStrategyContract): SeoChartDat function getSeoStrategyDemandBars(strategy: SeoStrategyContract): SeoChartDatum[] { return [ { - detail: "точные Wordstat seed", - label: "Exact", + detail: "точные фразы Wordstat", + label: "Точные", tone: strategy.evidence.demand.exactSeedCount > 0 ? "good" : "warning", value: strategy.evidence.demand.exactSeedCount }, { - detail: "use/support candidates", - label: "Useful", + detail: "полезные кандидаты", + label: "Полезные", tone: strategy.evidence.demand.usefulCandidateCount > 0 ? "good" : "warning", value: strategy.evidence.demand.usefulCandidateCount }, { detail: "требуют проверки", - label: "Risky", + label: "Риск", tone: strategy.evidence.demand.riskyCandidateCount > 0 ? "warning" : "good", value: strategy.evidence.demand.riskyCandidateCount }, { detail: "отфильтрованный шум", - label: "Trash", + label: "Шум", tone: strategy.evidence.demand.rejectedNoiseCount > 0 ? "problem" : "neutral", value: strategy.evidence.demand.rejectedNoiseCount } @@ -1513,14 +2934,14 @@ function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatu return [ { - detail: "уже дают evidence", - label: "Connected", + detail: "уже дают данные", + label: "Подключено", tone: connectedCount > 0 ? "good" : "warning", value: connectedCount }, { detail: "нужны для зрелой стратегии", - label: "Gaps", + label: "Пробелы", tone: plannedCount > 0 ? "warning" : "good", value: plannedCount } @@ -1530,8 +2951,8 @@ function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatu function getStrategySynthesisMetrics(strategySynthesis: StrategySynthesisContract): SeoChartDatum[] { return [ { - detail: "0-100, по evidence stack", - label: "Score", + detail: "0-100, по доказательной базе", + label: "Оценка", tone: getStrategyTone(strategySynthesis.readiness.confidenceScore), value: strategySynthesis.readiness.confidenceScore }, @@ -1543,7 +2964,7 @@ function getStrategySynthesisMetrics(strategySynthesis: StrategySynthesisContrac }, { detail: "чего не хватает", - label: "Gaps", + label: "Пробелы", tone: strategySynthesis.readiness.dataGapCount > 0 ? "warning" : "good", value: strategySynthesis.readiness.dataGapCount }, @@ -1559,26 +2980,26 @@ function getStrategySynthesisMetrics(strategySynthesis: StrategySynthesisContrac function getStrategyQualityMetrics(review: StrategyQualityReviewContract): SeoChartDatum[] { return [ { - detail: "quality gate score", - label: "Score", + detail: "оценка проверки", + label: "Оценка", tone: getStrategyTone(review.readiness.score), value: review.readiness.score }, { - detail: "checks passed", - label: "Pass", + detail: "проверок пройдено", + label: "Пройдено", tone: review.readiness.passedCount > 0 ? "good" : "warning", value: review.readiness.passedCount }, { - detail: "must fix", - label: "Blockers", + detail: "нужно исправить", + label: "Блокеры", tone: review.readiness.blockerCount > 0 ? "problem" : "good", value: review.readiness.blockerCount }, { - detail: "needs attention", - label: "Warnings", + detail: "нужно внимание", + label: "Предупреждения", tone: review.readiness.warningCount > 0 ? "warning" : "good", value: review.readiness.warningCount } @@ -2341,7 +3762,7 @@ function getPipelineStageAccess( if (stageIndex === 5) { return { - reason: marketReady && keywordReady ? "Есть спрос и карта фраз, можно смотреть SEO-план." : "Сначала нужен рыночный спрос и keyword map.", + reason: marketReady && keywordReady ? "Есть спрос и карта фраз, можно выбирать стратегию посадочных." : "Сначала нужен рыночный спрос и карта фраз.", status: marketReady && keywordReady ? "ready" : "locked", unlocked: marketReady && keywordReady }; @@ -2349,7 +3770,7 @@ function getPipelineStageAccess( if (stageIndex === 6) { return { - reason: strategyReady ? "Стратегия доступна: можно выбирать фазу старта." : "Сначала нужен SEO-план и evidence base.", + reason: strategyReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "Сначала нужна стратегическая база и доказательства.", status: strategyReady ? "ready" : "locked", unlocked: strategyReady }; @@ -2357,14 +3778,14 @@ function getPipelineStageAccess( if (stageIndex === 7) { return { - reason: synthesisReady ? "Strategy synthesis собран, можно проверять качество." : "Сначала собери strategy synthesis.", + reason: synthesisReady ? "Синтез стратегии собран, можно проверять качество." : "Сначала собери синтез стратегии.", status: synthesisReady ? "ready" : "locked", unlocked: synthesisReady }; } return { - reason: qualityReady ? "Quality gate пройден; apply всё ещё остаётся будущим контролируемым контуром." : "Применение заблокировано до quality review.", + reason: qualityReady ? "Проверка качества пройдена; применение всё ещё остаётся будущим контролируемым контуром." : "Применение заблокировано до проверки качества.", status: qualityReady ? "future" : "locked", unlocked: qualityReady }; @@ -2560,7 +3981,7 @@ function getBriefQualityCheckLabel(status: SemanticBriefQuality["checks"][number function getMarketStateLabel(state: MarketEnrichmentContract["state"]) { const labels = { not_collected: "не собрано", - not_configured: "provider не настроен", + not_configured: "провайдер не настроен", not_ready: "не готово", partial_ready: "частично готово" }; @@ -2711,12 +4132,12 @@ function getRewriteDiffSlotStatusLabel(status: RewriteDiffContract["targets"][nu function getModelTaskTypeLabel(taskType: SeoModelTaskType) { const labels = { - "seo.context_review": "Context Review", - "seo.keyword_cleaning": "Keyword Cleaning", - "seo.normalization": "Normalization", - "seo.serp_interpretation": "SERP Interpretation", - "seo.strategy_quality_review": "Strategy Quality", - "seo.strategy_synthesis": "Strategy Synthesis" + "seo.context_review": "Проверка контекста", + "seo.keyword_cleaning": "Очистка фраз", + "seo.normalization": "Нормализация", + "seo.serp_interpretation": "Разбор выдачи", + "seo.strategy_quality_review": "Проверка стратегии", + "seo.strategy_synthesis": "Синтез стратегии" }; return labels[taskType]; @@ -2724,9 +4145,9 @@ function getModelTaskTypeLabel(taskType: SeoModelTaskType) { function getModelTaskRunStatusLabel(status: SeoModelTaskRun["status"]) { const labels = { - contract_ready: "contract ready", - provider_not_configured: "provider не подключён", - task_not_found: "task не найден", + contract_ready: "задача подготовлена", + provider_not_configured: "провайдер не подключён", + task_not_found: "задача не найдена", validation_failed: "ошибка контракта" }; @@ -2735,9 +4156,9 @@ function getModelTaskRunStatusLabel(status: SeoModelTaskRun["status"]) { function getModelProviderStatusLabel(status: SeoModelProviderStatusContract["catalog"]["providers"][number]["status"]) { const labels = { - active: "active", - not_configured: "not configured", - planned: "planned" + active: "активен", + not_configured: "не настроен", + planned: "запланирован" }; return labels[status]; @@ -3616,6 +5037,8 @@ export function App() { const folderInputRef = useRef(null); const titleInputRef = useRef(null); const previewFrameRefs = useRef>({}); + const strategyExportButtonRef = useRef(null); + const strategyExportMenuRef = useRef(null); const renameCancelRef = useRef(false); const committingRenameProjectIdRef = useRef(null); const autoScannedProjectIdsRef = useRef(new Set()); @@ -3637,6 +5060,16 @@ export function App() { const [serpInterpretations, setSerpInterpretations] = useState>({}); const [strategySyntheses, setStrategySyntheses] = useState>({}); const [strategyQualityReviews, setStrategyQualityReviews] = useState>({}); + const [strategyForecastSettingsByProjectId, setStrategyForecastSettingsByProjectId] = useState>({}); + const [selectedStrategyScenarioIds, setSelectedStrategyScenarioIds] = useState>({}); + const [expandedStrategyPhraseGroups, setExpandedStrategyPhraseGroups] = useState>({}); + const [pendingStrategyDecisionProjectId, setPendingStrategyDecisionProjectId] = useState(null); + const [strategyDecisionContractsByProjectId, setStrategyDecisionContractsByProjectId] = useState< + Record + >({}); + const [isStrategyExportMenuOpen, setIsStrategyExportMenuOpen] = useState(false); + const [strategyExportMenuPosition, setStrategyExportMenuPosition] = useState<{ right: number; top: number } | null>(null); + const [exportingStrategyKey, setExportingStrategyKey] = useState(null); const [selectedStrategyPhaseIds, setSelectedStrategyPhaseIds] = useState< Record >({}); @@ -4852,6 +6285,91 @@ export function App() { } } + async function handleStrategyExport(project: ProjectSummary, format: StrategyExportFormat) { + const exportKey = `${project.id}:${format}`; + const expandedGroupIds = ["primary", "differentiator", "support", "article", "reject"]; + const expandedGroupKeys = expandedGroupIds.map((groupId) => `${project.id}:${groupId}`); + const previousExpandedGroupValues = expandedGroupKeys.reduce>((accumulator, key) => { + accumulator[key] = expandedStrategyPhraseGroups[key]; + return accumulator; + }, {}); + + setExportingStrategyKey(exportKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + setExpandedStrategyPhraseGroups((currentGroups) => { + const nextGroups = { ...currentGroups }; + + for (const key of expandedGroupKeys) { + nextGroups[key] = true; + } + + return nextGroups; + }); + await waitForDomPaint(); + + const snapshot = buildStrategyExportSnapshot({ + keywordMap: keywordMaps[project.id] ?? null, + project, + scanSummary: scanSummaries[project.id] ?? null, + selectedContract: strategyDecisionContractsByProjectId[project.id] ?? null, + selectedScenarioId: selectedStrategyScenarioIds[project.id] ?? null, + seoStrategy: seoStrategies[project.id] ?? null, + serpInterpretation: serpInterpretations[project.id] ?? null, + yandexEvidence: yandexEvidences[project.id] ?? null + }); + const exportSnapshot: StrategyExportSnapshot = { + ...snapshot, + domMirror: collectStrategyDomMirror() + }; + const fileBase = `${sanitizeExportFilename(project.name)}-strategy-${snapshot.generatedAt.slice(0, 10)}`; + + if (format === "markdown") { + downloadClientFile( + `${fileBase}.md`, + new Blob([renderStrategyExportMarkdown(exportSnapshot)], { type: "text/markdown;charset=utf-8" }) + ); + setProjectActionMessage(`Стратегия выгружена в Markdown: ${fileBase}.md`); + return; + } + + if (format === "json") { + downloadClientFile( + `${fileBase}.json`, + new Blob([JSON.stringify(exportSnapshot, null, 2)], { type: "application/json;charset=utf-8" }) + ); + setProjectActionMessage(`Стратегия выгружена в JSON: ${fileBase}.json`); + return; + } + + await downloadStrategyPdfMirror(project, `${fileBase}.pdf`); + setProjectActionMessage(`Стратегия выгружена в PDF: ${fileBase}.pdf`); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось выгрузить стратегию.")); + } finally { + setExpandedStrategyPhraseGroups((currentGroups) => { + const nextGroups = { ...currentGroups }; + + for (const key of expandedGroupKeys) { + const previousValue = previousExpandedGroupValues[key]; + + if (previousValue === undefined) { + delete nextGroups[key]; + } else { + nextGroups[key] = previousValue; + } + } + + return nextGroups; + }); + setExportingStrategyKey(null); + setIsStrategyExportMenuOpen(false); + } + } + async function handleSeoContextReview(project: ProjectSummary) { setReviewingContextProjectId(project.id); setProjectActionError(null); @@ -4932,12 +6450,12 @@ export function App() { [project.id]: statusResult.modelProvider })); setProjectActionMessage( - `${getModelTaskTypeLabel(taskType)} Codex task route: ${getModelTaskRunStatusLabel(result.modelTaskRun.status)} · ~${ + `${getModelTaskTypeLabel(taskType)}: ${getModelTaskRunStatusLabel(result.modelTaskRun.status)} · примерно ${ result.modelTaskRun.estimate.inputTokenEstimate - } input tokens.` + } входных токенов.` ); } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось подготовить Codex task route.")); + setProjectActionError(getErrorMessage(error, "Не удалось подготовить задачу модели.")); } finally { setRunningModelTaskKey(null); } @@ -5146,11 +6664,11 @@ export function App() { invalidateStrategyQualityReview(project.id); setProjectActionMessage( result.marketEnrichment.state === "not_configured" - ? "Market enrichment готов к запуску, но платформенный provider ещё не настроен." - : `Market enrichment: ${getMarketStateLabel(result.marketEnrichment.state)}.` + ? "Рыночное обогащение готово к запуску, но платформенный провайдер ещё не настроен." + : `Рыночное обогащение: ${getMarketStateLabel(result.marketEnrichment.state)}.` ); } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось выполнить market enrichment.")); + setProjectActionError(getErrorMessage(error, "Не удалось выполнить рыночное обогащение.")); } finally { setEnrichingMarketProjectId(null); } @@ -5184,12 +6702,12 @@ export function App() { [project.id]: strategyResult.strategy })); setProjectActionMessage( - `Yandex evidence собрано: ${result.yandexEvidence.summary.checkedPhraseCount} фраз, ${formatCount( + `Доказательства Яндекса собраны: ${result.yandexEvidence.summary.checkedPhraseCount} фраз, ${formatCount( result.yandexEvidence.summary.totalDemand - )} спрос, ${result.yandexEvidence.summary.serpDocCount} SERP docs.` + )} спрос, ${result.yandexEvidence.summary.serpDocCount} документов выдачи.` ); } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось собрать Yandex evidence.")); + setProjectActionError(getErrorMessage(error, "Не удалось собрать доказательства Яндекса.")); } finally { setCollectingYandexEvidenceProjectId(null); } @@ -5219,10 +6737,10 @@ export function App() { })); invalidateStrategyQualityReview(project.id); setProjectActionMessage( - `SERP interpretation: ${result.serpInterpretation.readiness.interpretedPhraseCount} фраз, ${result.serpInterpretation.readiness.competitorGapCount} competitor gaps, ${result.serpInterpretation.readiness.mismatchCount} mismatch.` + `Разбор выдачи собран: ${result.serpInterpretation.readiness.interpretedPhraseCount} фраз, ${result.serpInterpretation.readiness.competitorGapCount} конкурентных пробелов, ${result.serpInterpretation.readiness.mismatchCount} расхождений.` ); } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось собрать SERP interpretation.")); + setProjectActionError(getErrorMessage(error, "Не удалось собрать разбор выдачи.")); } finally { setInterpretingSerpProjectId(null); } @@ -5243,10 +6761,10 @@ export function App() { })); invalidateStrategyQualityReview(project.id); setProjectActionMessage( - `Strategy synthesis: ${result.strategySynthesis.readiness.decisionOptionCount} опции, ${result.strategySynthesis.readiness.dataGapCount} gaps, score ${result.strategySynthesis.readiness.confidenceScore}.` + `Синтез стратегии собран: ${result.strategySynthesis.readiness.decisionOptionCount} опции, ${result.strategySynthesis.readiness.dataGapCount} пробелов, оценка ${result.strategySynthesis.readiness.confidenceScore}.` ); } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось собрать strategy synthesis.")); + setProjectActionError(getErrorMessage(error, "Не удалось собрать синтез стратегии.")); } finally { setSynthesizingStrategyProjectId(null); } @@ -5949,6 +7467,76 @@ export function App() { ); const activeStageAccess = stageAccessList[activeStageIndex] ?? stageAccessList[0]; + function getStrategyExportMenuPosition() { + const button = strategyExportButtonRef.current; + + if (!button) { + return null; + } + + const rect = button.getBoundingClientRect(); + + return { + right: Math.max(12, window.innerWidth - rect.right), + top: rect.bottom + 8 + }; + } + + function toggleStrategyExportMenu() { + if (isStrategyExportMenuOpen) { + setIsStrategyExportMenuOpen(false); + return; + } + + setStrategyExportMenuPosition(getStrategyExportMenuPosition()); + setIsStrategyExportMenuOpen(true); + } + + useEffect(() => { + setIsStrategyExportMenuOpen(false); + }, [activeProjectId, activeStageIndex]); + + useEffect(() => { + if (!isStrategyExportMenuOpen) { + return; + } + + const syncMenuPosition = () => { + setStrategyExportMenuPosition(getStrategyExportMenuPosition()); + }; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target instanceof Node ? event.target : null; + + if (!target) { + return; + } + + if (strategyExportButtonRef.current?.contains(target) || strategyExportMenuRef.current?.contains(target)) { + return; + } + + setIsStrategyExportMenuOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsStrategyExportMenuOpen(false); + } + }; + + syncMenuPosition(); + window.addEventListener("resize", syncMenuPosition); + window.addEventListener("scroll", syncMenuPosition, true); + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("resize", syncMenuPosition); + window.removeEventListener("scroll", syncMenuPosition, true); + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isStrategyExportMenuOpen]); + useEffect(() => { if ( activeStageIndex !== 1 || @@ -6158,6 +7746,45 @@ export function App() { setActiveStageIndex(2); } + const strategyExportFormats = [ + ["markdown", "Markdown"], + ["json", "JSON"], + ["pdf", "PDF"] + ] as const; + const strategyExportMenuPortal = + activeProject && isStrategyExportMenuOpen && strategyExportMenuPosition + ? createPortal( +
+ {strategyExportFormats.map(([format, label]) => ( + + ))} +
, + document.body + ) + : null; + return (
+ {strategyExportMenuPortal}
@@ -6465,11 +8093,11 @@ export function App() { {service.id === "yandex_cloud" && yandexAiStudioProbe ? (
- Capability probe + Проверка возможностей {yandexAiStudioProbe.credential.configured ? `${yandexAiStudioProbe.credential.authType} · ${yandexAiStudioProbe.credential.folderId}` - : "credential not configured"} + : "доступ не настроен"}
    @@ -6648,10 +8276,29 @@ export function App() {
    {topbarStageActions}
    + {activeStageIndex === 5 ? ( +
    + +
    + ) : null}
- {workspaceMode === "dev" ? ( + {workspaceMode === "dev" && activeStageIndex !== 5 ? (
{activeStageAccess.unlocked ? "этап доступен" : "этап закрыт"} @@ -6729,14 +8376,14 @@ export function App() {
Вариант C AI-автоматизация бизнес-процессов - Целевой rewrite-контур без ручной SEO-аналитики на первом экране. + Целевой контур переписывания без ручной SEO-аналитики на первом экране.
) : null}
- {activeStageIndex === 1 ? null : ( + {activeStageIndex === 1 || activeStageIndex === 5 ? null : (
@@ -6946,6 +8593,7 @@ export function App() { const rewritePlan = rewritePlans[project.id] ?? null; const materialization = materializationContracts[project.id] ?? null; const rewriteDiff = rewriteDiffContracts[project.id] ?? null; + const acceptedStrategyDecisionContract = strategyDecisionContractsByProjectId[project.id] ?? null; const stageDecisionFrame = stageDecisionFrames[activeStageIndex] ?? null; const marketDecision = marketEnrichment ? getMarketDecision(marketEnrichment) : null; const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; @@ -7017,6 +8665,1363 @@ export function App() { const selectedSynthesisPhaseStrategy = strategySynthesis?.phaseStrategies.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null; const selectedStrategyNarrative = selectedSynthesisPhaseStrategy ?? selectedPhaseStrategy; + const currentStrategyNarrative = seoStrategy + ? (strategySynthesis?.phaseStrategies.find((phase) => phase.phase === seoStrategy.siteMaturity.phase) ?? + seoStrategy.phaseStrategies.find((phase) => phase.phase === seoStrategy.siteMaturity.phase) ?? + null) + : null; + const strategyDecisionTitle = + formatStrategyDisplayText( + strategySynthesis?.state === "ready" + ? strategySynthesis.recommendedPath.title + : currentStrategyNarrative?.title ?? seoStrategy?.verdict.title ?? "стратегия ещё не собрана" + ); + const strategyDecisionSummary = + formatStrategyDisplayText( + strategySynthesis?.state === "ready" + ? strategySynthesis.executiveSummary.verdict + : currentStrategyNarrative?.summary ?? + seoStrategy?.verdict.summary ?? + "Сначала нужны контекст, рыночный спрос и карта фраз." + ); + const strategyDecisionWhy = + formatStrategyDisplayText( + strategySynthesis?.state === "ready" + ? strategySynthesis.executiveSummary.whyNow + : seoStrategy?.siteMaturity.reason ?? + "Стратегия появится только после доказательной базы, без правки исходного сайта." + ); + const strategyNowActions = ( + strategySynthesis?.recommendedPath.doNow ?? + currentStrategyNarrative?.doNow ?? + seoStrategy?.siteMaturity.allowedNow ?? + seoStrategy?.nextActions ?? + [] + ) + .slice(0, 4) + .map(formatStrategyDisplayText); + const strategyPrepareActions = ( + strategySynthesis?.phaseDecision.prepareNext ?? + currentStrategyNarrative?.prepareNext ?? + seoStrategy?.siteMaturity.promotionSignals ?? + [] + ) + .slice(0, 4) + .map(formatStrategyDisplayText); + const strategyHoldActions = ( + strategySynthesis?.blockedActions ?? + strategySynthesis?.recommendedPath.hold ?? + currentStrategyNarrative?.hold ?? + seoStrategy?.siteMaturity.holdUntil ?? + [] + ) + .slice(0, 5) + .map(formatStrategyDisplayText); + const strategyCanPlan = Boolean( + seoStrategy && + seoStrategy.state !== "blocked" && + seoStrategy.readiness.approvedKeywordCount > 0 && + strategyNowActions.length > 0 + ); + const strategyCanRewrite = false; + const strategyCanApply = false; + const strategyEvidenceCards = seoStrategy + ? [ + { + detail: seoStrategy.siteMaturity.signals.hasIndexEvidence + ? "есть данные по индексации" + : "собственных поисковых сигналов нет", + label: "Сайт", + tone: seoStrategy.siteMaturity.signals.hasIndexEvidence ? ("good" as const) : ("warning" as const), + value: getSeoMaturityPhaseLabel(seoStrategy.siteMaturity.phase) + }, + { + detail: `${seoStrategy.evidence.demand.exactSeedCount} точных фраз, ${seoStrategy.evidence.demand.rejectedNoiseCount} шумных отклонено`, + label: "Спрос", + tone: seoStrategy.evidence.demand.totalFrequency > 0 ? ("good" as const) : ("warning" as const), + value: formatCount(yandexEvidence?.summary.totalDemand ?? seoStrategy.evidence.demand.totalFrequency) + }, + { + detail: + serpInterpretation?.state === "ready" + ? `${serpInterpretation.readiness.interpretedPhraseCount} фраз объяснено` + : "сырые домены не считаются стратегией", + label: "Выдача", + tone: serpInterpretation?.state === "ready" ? ("good" as const) : ("warning" as const), + value: + serpInterpretation?.state === "ready" + ? "разобрана" + : yandexEvidence?.summary.serpDocCount + ? `${yandexEvidence.summary.serpDocCount} док.` + : "нет" + }, + { + detail: + seoStrategy.opportunities[0]?.targetPath ?? + currentStrategyNarrative?.landingPlan.find((landing) => landing.targetPath)?.targetPath ?? + "посадочная ещё не выбрана", + label: "Страницы", + tone: seoStrategy.opportunities.length > 0 ? ("good" as const) : ("warning" as const), + value: `${seoStrategy.opportunities.length} групп` + }, + { + detail: + seoStrategy.missingEvidence.length > 0 + ? "нужно закрыть перед зрелым решением" + : "критичных пробелов нет", + label: "Блокеры", + tone: seoStrategy.missingEvidence.length > 0 ? ("warning" as const) : ("good" as const), + value: `${seoStrategy.missingEvidence.length}` + } + ] + : []; + const strategyForecastSettings = + strategyForecastSettingsByProjectId[project.id] ?? DEFAULT_STRATEGY_FORECAST_SETTINGS; + const updateStrategyForecastSettings = (patch: Partial) => { + setStrategyForecastSettingsByProjectId((currentSettings) => ({ + ...currentSettings, + [project.id]: { + ...(currentSettings[project.id] ?? DEFAULT_STRATEGY_FORECAST_SETTINGS), + ...patch + } + })); + }; + const strategyKeywordRows = getStrategyKeywordRows(keywordMap, serpInterpretation); + const strategyCommercialKeywordRows = strategyKeywordRows.filter( + (row) => row.role === "primary" || row.role === "secondary" || row.role === "support" + ); + const strategyArticleOrDeferredRows = strategyKeywordRows.filter( + (row) => row.role === "article" || row.role === "defer" || row.targetFit === "mismatch" || !row.targetPath + ); + const strategyDemandByTargetPath = strategyCommercialKeywordRows.reduce>((accumulator, row) => { + if (!row.targetPath) { + return accumulator; + } + + accumulator.set(row.targetPath, (accumulator.get(row.targetPath) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); + const strategyPrimaryTargetPath = + Array.from(strategyDemandByTargetPath.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? + seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? + strategyKeywordRows.find((row) => row.targetPath)?.targetPath ?? + null; + const strategyRowsForPrimaryTarget = strategyPrimaryTargetPath + ? strategyKeywordRows.filter((row) => row.targetPath === strategyPrimaryTargetPath) + : []; + const strategySelectedKeywordRows = strategyRowsForPrimaryTarget.filter( + (row) => + (row.role === "primary" || row.role === "secondary" || row.role === "support") && + row.targetFit !== "mismatch" && + row.intent !== "informational" + ); + const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget.reduce>((accumulator, row) => { + accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); + const strategyPrimaryCluster = + Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? + seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ?? + seoStrategy?.opportunities[0]?.title ?? + strategyKeywordRows[0]?.clusterTitle ?? + "кластер не выбран"; + const strategyKeywordDemandTotal = strategyKeywordRows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); + const strategyTotalAnalyzedDemand = + [ + serpInterpretation?.yandexEvidence.totalDemand, + yandexEvidence?.summary.totalDemand, + seoStrategy?.evidence.yandex.totalDemand, + seoStrategy?.evidence.demand.totalFrequency + ].find((value) => typeof value === "number" && value > 0) ?? strategyKeywordDemandTotal; + const strategyPrimaryOpportunity = + seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath) ?? + seoStrategy?.opportunities[0] ?? + null; + const strategySelectedDemandFromRows = strategySelectedKeywordRows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); + const strategySelectedDemand = + strategySelectedDemandFromRows > 0 + ? strategySelectedDemandFromRows + : Math.min(strategyPrimaryOpportunity?.demand.totalFrequency ?? 0, strategyTotalAnalyzedDemand || Number.MAX_SAFE_INTEGER); + const strategyDeferredDemandFromRows = strategyArticleOrDeferredRows.reduce( + (sum, row) => sum + (row.frequency ?? 0), + 0 + ); + const strategyRawPortfolioDemand = strategyRowsForPrimaryTarget.reduce((sum, row) => sum + (row.frequency ?? 0), 0); + const strategyExcludedRows = strategyKeywordRows.filter((row) => row.role === "trash"); + const strategyExcludedDemandFromRows = strategyExcludedRows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); + const strategyDeferredDemand = Math.max(strategyTotalAnalyzedDemand - strategySelectedDemand, 0); + const strategyTargetPageCount = new Set(strategyCommercialKeywordRows.map((row) => row.targetPath).filter(Boolean)).size; + const strategyPersistedPageByPath = new Map(); + + (keywordMap?.persisted.items ?? []).forEach((item) => { + if (item.targetPath && item.pageId && !strategyPersistedPageByPath.has(item.targetPath)) { + strategyPersistedPageByPath.set(item.targetPath, item.pageId); + } + }); + (scanSummary?.pages ?? []).forEach((page) => { + if (page.urlPath && !strategyPersistedPageByPath.has(page.urlPath)) { + strategyPersistedPageByPath.set(page.urlPath, page.pageId); + } + }); + const strategyPrimaryPageId = strategyPrimaryTargetPath + ? (strategyPersistedPageByPath.get(strategyPrimaryTargetPath) ?? null) + : null; + const strategyPrimaryPageStatus = strategyPrimaryPageId + ? "страница связана" + : strategyPrimaryTargetPath + ? "нужно связать или создать посадочную" + : "посадочная не выбрана"; + const strategyPageReadyDemand = strategyPrimaryPageId ? strategySelectedDemand : 0; + const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand; + const strategySecondaryClusters = Array.from( + strategyRowsForPrimaryTarget.reduce>((accumulator, row) => { + if (row.clusterTitle === strategyPrimaryCluster) { + return accumulator; + } + + accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()) + ) + .sort((left, right) => right[1] - left[1]) + .map(([cluster]) => cluster) + .slice(0, 3); + const strategyDifferentiatorCluster = + strategySecondaryClusters[0] ?? null; + const strategyDisplayKeywordRows: StrategyKeywordDecisionRow[] = strategyKeywordRows.map((row) => { + if ( + row.targetPath === strategyPrimaryTargetPath && + row.role === "primary" && + row.clusterTitle !== strategyPrimaryCluster + ) { + return { + ...row, + reason: "Кластер поддерживает основную посадочную как отличающий слой, но не меняет главную ставку страницы.", + role: "differentiator" + }; + } + + return row; + }); + + const strategyClusterGroups = strategyDisplayKeywordRows.reduce>( + (accumulator, row) => { + const group = accumulator.get(row.clusterTitle) ?? []; + group.push(row); + accumulator.set(row.clusterTitle, group); + return accumulator; + }, + new Map() + ); + const strategyClusterRowsFromKeywords = Array.from(strategyClusterGroups.entries()).map(([clusterTitle, rows]) => { + const opportunity = + seoStrategy?.opportunities.find( + (item) => item.title === clusterTitle || rows.some((row) => row.phrase === item.demand.topPhrase) + ) ?? null; + const demandFromRows = rows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); + const targetPath = + getStrategyMostCommonValue(rows.map((row) => row.targetPath).filter((value): value is string => Boolean(value))) ?? + opportunity?.targetPath ?? + null; + const topRow = [...rows].sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))[0] ?? null; + const hasIntentRisk = rows.some( + (row) => row.role === "article" || row.targetFit === "mismatch" || row.intent === "informational" + ); + const isSelectedCluster = clusterTitle === strategyPrimaryCluster; + const isPrimaryTarget = targetPath === strategyPrimaryTargetPath; + const action: StrategyTargetAction = !targetPath + ? "defer" + : hasIntentRisk && (isSelectedCluster || isPrimaryTarget) + ? "partial" + : hasIntentRisk + ? "defer" + : isSelectedCluster || isPrimaryTarget + ? "strengthen" + : "strengthen"; + + return { + action, + clusterTitle, + demand: + strategyTotalAnalyzedDemand > 0 + ? Math.min(demandFromRows || opportunity?.demand.totalFrequency || 0, strategyTotalAnalyzedDemand) + : demandFromRows || opportunity?.demand.totalFrequency || 0, + evidenceRefs: Array.from(new Set(rows.flatMap((row) => row.evidenceRefs).concat(opportunity?.evidenceRefs ?? []))), + intent: getStrategyIntentDecisionLabel(topRow?.intent), + reason: formatStrategyDisplayText( + action === "partial" + ? "Кластер выбран как ставка, но точные широкие или информационные фразы не становятся основной ставкой без отдельного подтверждения выдачи." + : topRow?.reason ?? + opportunity?.whyItMatters ?? + "Есть кластер, но нужно добрать доказательство по интенту и посадочной." + ), + risk: hasIntentRisk + ? action === "partial" + ? "коммерческое ядро в посадочную; широкий слой отдельно" + : "риск широкого или информационного интента" + : targetPath + ? "следить за смешением соседних кластеров" + : "нет подтверждённой посадочной", + targetPath + }; + }); + const strategyClusterRows = ( + strategyClusterRowsFromKeywords.length > 0 + ? strategyClusterRowsFromKeywords + : (seoStrategy?.opportunities ?? []).map((opportunity) => ({ + action: (opportunity.timing.action === "defer" ? "defer" : "strengthen") as StrategyTargetAction, + clusterTitle: opportunity.title, + demand: + strategyTotalAnalyzedDemand > 0 + ? Math.min(opportunity.demand.totalFrequency, strategyTotalAnalyzedDemand) + : opportunity.demand.totalFrequency, + evidenceRefs: opportunity.evidenceRefs, + intent: "нужно проверить", + reason: formatStrategyDisplayText(opportunity.whyItMatters || opportunity.recommendation), + risk: formatStrategyDisplayText(opportunity.timing.reason), + targetPath: opportunity.targetPath + })) + ) + .filter( + (row) => + row.clusterTitle && + row.reason && + (row.demand > 0 || row.evidenceRefs.length > 0) + ) + .sort((left, right) => right.demand - left.demand) + .slice(0, 8); + const strategyVisibleKeywordRows = strategyDisplayKeywordRows + .filter((row) => row.targetPath || row.frequency !== null || row.role === "article" || row.role === "defer"); + const strategyKeywordRoleGroups = [ + { + id: "primary", + label: "Основная ставка", + rows: strategyVisibleKeywordRows.filter((row) => row.role === "primary") + }, + { + id: "differentiator", + label: "Дифференциатор", + rows: strategyVisibleKeywordRows.filter((row) => row.role === "differentiator") + }, + { + id: "support", + label: "Поддержка", + rows: strategyVisibleKeywordRows.filter((row) => row.role === "secondary" || row.role === "support") + }, + { + id: "article", + label: "В статью / очередь", + rows: strategyVisibleKeywordRows.filter((row) => row.role === "article" || row.role === "defer") + }, + { + id: "reject", + label: "Исключено", + rows: strategyVisibleKeywordRows.filter((row) => row.role === "trash") + } + ].filter((group) => group.rows.length > 0); + const isStrategyLandingKeywordRow = (row: StrategyKeywordDecisionRow) => + row.role !== "article" && + row.role !== "defer" && + row.targetFit !== "mismatch" && + row.intent !== "informational"; + const strategyLandingGroups = strategyDisplayKeywordRows.reduce>((accumulator, row) => { + if (!row.targetPath || !isStrategyLandingKeywordRow(row)) { + return accumulator; + } + + const group = accumulator.get(row.targetPath) ?? []; + group.push(row); + accumulator.set(row.targetPath, group); + return accumulator; + }, new Map()); + const strategyLandingRows = Array.from(strategyLandingGroups.entries()).map(([targetPath, rows]) => { + const pageId = strategyPersistedPageByPath.get(targetPath) ?? null; + const hasCommercialRows = rows.some( + (row) => + row.role === "primary" || + row.role === "secondary" || + row.role === "support" || + row.role === "differentiator" + ); + const pageRole: StrategyPageRole = + targetPath === "/" + ? "home" + : targetPath.includes("/docs") + ? "docs" + : targetPath === strategyPrimaryTargetPath || hasCommercialRows + ? "commercial_landing" + : rows.some((row) => row.role === "article") + ? "info_article" + : "commercial_landing"; + const action: StrategyTargetAction = + targetPath === "/" + ? strategyPrimaryTargetPath === "/" + ? "strengthen" + : "keep" + : targetPath === strategyPrimaryTargetPath + ? pageId + ? "strengthen" + : "create" + : pageRole === "info_article" + ? "defer" + : "strengthen"; + const targetClusters = Array.from(new Set(rows.map((row) => row.clusterTitle))).slice(0, 3); + const requiredBlocks = + pageRole === "home" + ? ["брендовая роль страницы", "внутренняя ссылка на выбранную посадочную", "без переспама широкими фразами"] + : [ + "SEO-заголовок", + "мета-описание", + "H1", + ...targetClusters.slice(0, 2).map((cluster) => `блок кластера: ${cluster}`), + "FAQ-блок", + "внутренние ссылки" + ]; + + return { + action, + demand: + targetPath === strategyPrimaryTargetPath + ? strategySelectedDemand + : rows.reduce((sum, row) => sum + (row.frequency ?? 0), 0), + pageId, + pageRole, + reason: + targetPath === strategyPrimaryTargetPath + ? "Основная посадочная для выбранного кластера и следующего плана правок." + : pageRole === "home" + ? "Главная держит общий продуктовый смысл и не должна становиться одной узкой SEO-страницей." + : "Страница может поддержать выбранный кластер, но не заменяет основную посадочную.", + requiredBlocks, + status: pageId ? "страница связана" : "нужно связать или создать посадочную", + targetClusters, + targetPath + }; + }); + const hasHomePage = (scanSummary?.pages ?? []).some( + (page) => page.urlPath === "/" || page.previewKind === "home_section" + ); + + if (hasHomePage && strategyPrimaryTargetPath && strategyPrimaryTargetPath !== "/" && !strategyLandingRows.some((row) => row.targetPath === "/")) { + const homePageId = strategyPersistedPageByPath.get("/") ?? null; + strategyLandingRows.unshift({ + action: "keep", + demand: 0, + pageId: homePageId, + pageRole: "home", + reason: "Главную не превращаем в посадочную одного кластера, пока выбран отдельный URL посадочной.", + requiredBlocks: ["общая роль продукта", "переход на выбранную посадочную", "без точного переспама"], + status: homePageId ? "страница связана" : "нужна явная связь с главной", + targetClusters: [strategyPrimaryCluster], + targetPath: "/" + }); + } + + if (strategyArticleOrDeferredRows.length > 0) { + const deferredClusters = Array.from(new Set(strategyArticleOrDeferredRows.map((row) => row.clusterTitle))).slice(0, 3); + strategyLandingRows.push({ + action: "defer", + demand: strategyDeferredDemand, + pageId: null, + pageRole: "info_article", + reason: "Широкие или спорные фразы не идут в главную посадочную без отдельного интента и URL.", + requiredBlocks: ["выбрать URL", "проверить выдачу", "собрать структуру статьи или справочного раздела", "перелинковать с основной посадочной"], + status: "нужен URL посадочной", + targetClusters: deferredClusters.length > 0 ? deferredClusters : [strategyPrimaryCluster], + targetPath: "URL не выбран" + }); + } + + const strategyLandingDecisionRows = strategyLandingRows + .sort((left, right) => { + if (left.targetPath === strategyPrimaryTargetPath) return -1; + if (right.targetPath === strategyPrimaryTargetPath) return 1; + if (left.targetPath === "/") return -1; + if (right.targetPath === "/") return 1; + return right.demand - left.demand; + }) + .slice(0, 8); + const strategyPrimaryKeyword = + strategySelectedKeywordRows.find((row) => row.clusterTitle === strategyPrimaryCluster && row.role === "secondary") ?? + strategySelectedKeywordRows.find((row) => row.clusterTitle === strategyPrimaryCluster) ?? + strategyRowsForPrimaryTarget.find((row) => row.clusterTitle === strategyPrimaryCluster && row.role !== "article") ?? + strategyRowsForPrimaryTarget[0] ?? + strategyVisibleKeywordRows[0] ?? + null; + const strategySupportKeywords = strategyRowsForPrimaryTarget + .filter((row) => row.role === "secondary" || row.role === "support") + .slice(0, 4); + const strategyBrandName = + projectOntology?.brandName?.trim() || semanticAnalysis?.projectOntology?.brandName?.trim() || ""; + const strategyPathProductLabel = getReadablePathProductLabel(strategyPrimaryTargetPath); + const strategyProductName = + strategyBrandName && strategyPathProductLabel + ? strategyBrandName.toLocaleLowerCase("ru-RU").includes(strategyPathProductLabel.toLocaleLowerCase("ru-RU")) + ? strategyBrandName + : `${strategyBrandName} ${strategyPathProductLabel}` + : strategyBrandName || strategyPathProductLabel || strategyPrimaryTargetPath || "Посадочная"; + const strategyTitleDifferentiator = + strategyDifferentiatorCluster && strategyDifferentiatorCluster !== strategyPrimaryCluster + ? strategyDifferentiatorCluster + : (strategyDisplayKeywordRows.find((row) => row.role === "differentiator")?.phrase ?? null); + const strategySeoTitleCandidate = + strategyPrimaryCluster !== "кластер не выбран" + ? `${strategyProductName} — ${formatLowerFirst(strategyPrimaryCluster)}` + : "Нужен главный кластер перед генерацией заголовка"; + const strategyMetaDescriptionCandidate = + strategyPrimaryCluster !== "кластер не выбран" + ? `${strategyProductName}: ${formatLowerFirst(strategyPrimaryCluster)}. ${ + strategyTitleDifferentiator ? `Отдельный акцент - ${formatLowerFirst(strategyTitleDifferentiator)}. ` : "" + }Сценарии, роли, процессы и контроль результата.` + : "Нужен главный кластер и продуктовая роль посадочной."; + const strategyPlanPreviewRows = [ + { + field: "SEO-заголовок", + reason: + formatStrategyEvidenceRef(strategyPrimaryKeyword?.evidenceRefs[0]) ?? + "title держит продукт и главный кластер без перегруза отличающим слоем", + value: strategySeoTitleCandidate + }, + { + field: "Черновик описания страницы", + reason: + formatStrategyEvidenceRef(strategySupportKeywords[0]?.evidenceRefs[0]) ?? + "дифференциатор и поддерживающие фразы уходят в описание и секции, а не перегружают title", + value: strategyMetaDescriptionCandidate + }, + { + field: "H1", + reason: "H1 фиксирует главную ставку. Вторичные углы уходят в секции, а не меняют позиционирование.", + value: + strategyPrimaryCluster !== "кластер не выбран" + ? strategyPrimaryCluster + : (strategyPrimaryKeyword?.phrase ?? "Нужен главный кластер") + }, + { + field: "Секции страницы", + reason: "Секции строятся из поддерживающих кластеров, не из сырого списка фраз.", + value: + strategySecondaryClusters.length > 0 + ? strategySecondaryClusters.slice(0, 3).join(" · ") + : "Нужны поддерживающие кластеры" + }, + { + field: "FAQ и разметка", + reason: "Разметку выбираем после подтверждения типа посадочной и фактов.", + value: "FAQ-блок, разметка по роли страницы, без неподтверждённых обещаний" + }, + { + field: "Внутренние ссылки", + reason: "Стратегия должна показать, откуда пользователь и поисковик попадут на посадочную.", + value: + strategyPrimaryTargetPath && strategyPrimaryTargetPath !== "/" + ? `Связать главную и близкие разделы с ${strategyPrimaryTargetPath}` + : "Нужна карта внутренних переходов" + } + ]; + const strategyBlockedMoveMap = new Map(); + const addStrategyBlockedMove = (title: string, reason: string, evidenceRefs: string[] = []) => { + if (!strategyBlockedMoveMap.has(title)) { + strategyBlockedMoveMap.set(title, { evidenceRefs, reason: formatStrategyDisplayText(reason), title }); + } + }; + + if (strategyArticleOrDeferredRows.length > 0) { + addStrategyBlockedMove( + "Не брать точные широкие или инфо-фразы как основную ставку без подтверждения выдачи", + `Кластер можно брать, но точные широкие формулировки уходят в статью, очередь или ручную проверку: ${strategyArticleOrDeferredRows + .slice(0, 3) + .map((row) => `«${row.phrase}»`) + .join(", ")}.`, + strategyArticleOrDeferredRows.flatMap((row) => row.evidenceRefs).slice(0, 3) + ); + } + if (strategyPrimaryTargetPath && strategyPrimaryTargetPath !== "/") { + addStrategyBlockedMove( + "Не переписывать главную под один кластер", + "Главная должна сохранять общую роль сайта, а выбранный кластер ведём на отдельную посадочную." + ); + } + addStrategyBlockedMove( + "Не смешивать кластеры без явного URL посадочной", + "Каждый видимый кластер должен иметь страницу, действие и причину. Иначе это остаётся в деталях, а не в стратегии." + ); + addStrategyBlockedMove( + "Не применять без связанной посадочной, сравнения изменений и проверки", + "Stage 06 только выбирает стратегию. Исходники сайта не меняются до отдельного этапа применения." + ); + (keywordMap?.blockers ?? []).slice(0, 2).forEach((blocker) => { + addStrategyBlockedMove("Закрыть блокер карты фраз", blocker); + }); + const strategyBlockedMoves = Array.from(strategyBlockedMoveMap.values()).slice(0, 4); + const strategyCtrRange = STRATEGY_CTR_RANGES[strategyForecastSettings.ctrModel]; + const buildStrategyScenario = ({ + businessFit, + cta, + demand, + effort, + id, + isRecommended, + label, + maxMultiplier = 1, + minMultiplier = 1, + outcome, + pages, + readiness, + risk, + title, + when, + why + }: { + businessFit: StrategyDecisionScenario["businessFit"]; + cta: string; + demand: number; + effort: StrategyDecisionScenario["effort"]; + id: string; + isRecommended: boolean; + label: string; + maxMultiplier?: number; + minMultiplier?: number; + outcome: string; + pages: string[]; + readiness: StrategyDecisionScenario["readiness"]; + risk: StrategyDecisionScenario["risk"]; + title: string; + when: string; + why: string; + }): StrategyDecisionScenario => { + const normalizedDemand = Math.max(0, Math.round(demand)); + const visibilityMin = strategyCtrRange.min * minMultiplier; + const visibilityMax = strategyCtrRange.max * maxMultiplier; + const trafficMin = normalizedDemand * visibilityMin; + const trafficMax = normalizedDemand * visibilityMax; + + return { + businessFit, + cta, + demand: normalizedDemand, + effort, + id, + isRecommended, + label, + leadsMax: trafficMax * strategyForecastSettings.conversionRate, + leadsMin: trafficMin * strategyForecastSettings.conversionRate, + outcome, + pages, + readiness, + risk, + title, + trafficMax, + trafficMin, + visibilityMax, + visibilityMin, + when, + why + }; + }; + const strategyHomepageOnlyDemand = + strategyPrimaryTargetPath === "/" + ? strategySelectedDemand + : Math.min(strategySelectedDemand, Math.max(1000, Math.round(strategySelectedDemand * 0.35))); + const strategyDecisionScenarios: StrategyDecisionScenario[] = [ + buildStrategyScenario({ + businessFit: strategyPrimaryTargetPath ? "high" : "medium", + cta: strategyPrimaryPageId ? "зафиксировать и открыть план" : "зафиксировать и связать посадочную", + demand: strategySelectedDemand, + effort: strategyPrimaryPageId ? "medium" : "medium", + id: "landing_first", + isRecommended: true, + label: "A · рекомендовано", + outcome: "рекомендовано", + pages: [strategyPrimaryTargetPath ?? "посадочная не выбрана"], + readiness: strategyPrimaryPageId ? "high" : "medium", + risk: "medium", + title: `${strategyPrimaryTargetPath ?? "посадочная"} первой`, + when: "нужна первая коммерческая SEO-ставка без размазывания фокуса", + why: `Берём ${formatCount(strategySelectedDemand)} из ${formatCount(strategyTotalAnalyzedDemand)} спроса и не смешиваем широкий интент с главной.` + }), + buildStrategyScenario({ + businessFit: "medium", + cta: "оставить как осторожный вариант", + demand: strategyHomepageOnlyDemand, + effort: "low", + id: "homepage_only", + isRecommended: false, + label: "B · осторожно", + maxMultiplier: 0.8, + minMultiplier: 0.7, + outcome: "безопасно, но меньше спроса", + pages: ["/"], + readiness: "high", + risk: "low", + title: "Только текущие страницы", + when: "если нельзя создавать или связывать посадочные", + why: "Меньше работ и риска, но заметно меньше выбранного спроса." + }), + buildStrategyScenario({ + businessFit: "high", + cta: "держать как ростовой вариант", + demand: Math.max(strategyTotalAnalyzedDemand, strategySelectedDemand), + effort: "high", + id: "landing_plus_article", + isRecommended: false, + label: "C · рост", + maxMultiplier: 1.15, + outcome: "следующий слой", + pages: [strategyPrimaryTargetPath ?? "посадочная", "будущий справочный слой"], + readiness: "medium", + risk: "medium", + title: "Посадочная + справочный слой", + when: "если нужен больший охват после первой посадочной", + why: "Закрывает коммерческий слой и отдельно выносит широкий информационный интент." + }), + buildStrategyScenario({ + businessFit: "medium", + cta: "не рекомендовано", + demand: Math.max(strategyTotalAnalyzedDemand, strategySelectedDemand), + effort: "medium", + id: "broad_attack", + isRecommended: false, + label: "D · агрессивно", + maxMultiplier: 0.9, + minMultiplier: 0.6, + outcome: "не сейчас", + pages: [strategyPrimaryTargetPath ?? "/", "/"], + readiness: "low", + risk: "high", + title: "Широкий спрос сразу", + when: "только после ручного решения по риску интента", + why: "Максимальный охват на бумаге, но высокий риск смешать позиционирование и получить нерелевантную выдачу." + }) + ]; + const strategyRecommendedScenario = strategyDecisionScenarios.find((scenario) => scenario.isRecommended) ?? strategyDecisionScenarios[0]; + const selectedStrategyScenarioId = + selectedStrategyScenarioIds[project.id] ?? strategyRecommendedScenario?.id ?? strategyDecisionScenarios[0]?.id ?? ""; + const selectedStrategyScenario = + strategyDecisionScenarios.find((scenario) => scenario.id === selectedStrategyScenarioId) ?? + strategyRecommendedScenario ?? + strategyDecisionScenarios[0]; + const strategyDecisionSummaryRows: StrategyDemandLedgerRow[] = [ + { + detail: selectedStrategyScenario?.title ?? "сценарий не выбран", + label: "Сценарий", + tone: selectedStrategyScenario ? getStrategyScenarioTone(selectedStrategyScenario) : "warning", + value: selectedStrategyScenario?.label ?? "нужно выбрать" + }, + { + detail: strategyPrimaryCluster, + label: "Посадочная", + tone: strategyPrimaryTargetPath ? "good" : "warning", + value: strategyPrimaryTargetPath ?? "нужно выбрать URL" + }, + { + detail: "кластер берём, но не каждую точную фразу делаем основной", + label: "Основная ставка", + tone: strategyPrimaryCluster !== "кластер не выбран" ? "good" : "warning", + value: strategyPrimaryCluster + }, + { + detail: "идёт в следующий план правок", + label: "Спрос в плане", + tone: "good", + value: formatCount(strategySelectedDemand) + }, + { + detail: "широкий слой, статьи или ручная проверка", + label: "Отложено", + tone: strategyDeferredDemand > 0 ? "warning" : "neutral", + value: formatCount(strategyDeferredDemand) + }, + { + detail: `${STRATEGY_CTR_RANGES[strategyForecastSettings.ctrModel].label}, ${formatPercent( + strategyCtrRange.min + )}–${formatPercent(strategyCtrRange.max)} видимости, ${formatPercent( + strategyForecastSettings.conversionRate + )} конверсии, ${strategyForecastSettings.horizonMonths} мес.`, + label: "Модель прогноза", + tone: "neutral", + value: selectedStrategyScenario + ? formatForecastRange(selectedStrategyScenario.trafficMin, selectedStrategyScenario.trafficMax, " визитов/мес") + : "n/a" + }, + { + detail: strategyPrimaryTargetPath + ? `Этап 07 получит план правок для ${strategyPrimaryTargetPath}` + : "без URL план правок не открываем", + label: "Следующий этап", + tone: strategyPrimaryTargetPath ? "good" : "warning", + value: strategyPrimaryPageId ? "план правок" : "связать посадочную" + } + ]; + const strategyDemandFunnelItems: SeoChartDatum[] = [ + { + detail: "весь проверенный пул", + label: "Спрос в анализе", + tone: "neutral", + value: strategyTotalAnalyzedDemand + }, + { + detail: strategyPrimaryTargetPath ?? "посадочная не выбрана", + label: "Выбрано в работу", + tone: "good", + value: strategySelectedDemand + }, + { + detail: strategyPrimaryPageStatus, + label: "Готово к плану", + tone: strategyPageReadyDemand > 0 ? "good" : "warning", + value: strategyPageReadyDemand + }, + { + detail: strategyNeedsLandingDemand > 0 ? "нужна посадочная" : "посадочная есть", + label: "Требует связи", + tone: strategyNeedsLandingDemand > 0 ? "warning" : "neutral", + value: strategyNeedsLandingDemand + }, + { + detail: "статьи, справочный слой или ручная проверка", + label: "Отложено", + tone: "warning", + value: strategyDeferredDemand + } + ]; + const strategyScenarioDemandBars: SeoChartDatum[] = strategyDecisionScenarios.map((scenario) => ({ + detail: `${formatForecastRange(scenario.trafficMin, scenario.trafficMax, " визитов/мес")} · риск ${getStrategyRiskLabel(scenario.risk)}`, + label: scenario.label, + tone: getStrategyScenarioTone(scenario), + value: scenario.demand + })); + const strategyForecastFormulaRows = selectedStrategyScenario + ? [ + `${formatCount(selectedStrategyScenario.demand)} спроса × ${formatPercent( + selectedStrategyScenario.visibilityMin + )}–${formatPercent(selectedStrategyScenario.visibilityMax)} видимости = ${formatForecastRange( + selectedStrategyScenario.trafficMin, + selectedStrategyScenario.trafficMax, + " визитов/мес" + )}`, + `${formatForecastRange( + selectedStrategyScenario.trafficMin, + selectedStrategyScenario.trafficMax, + " визитов" + )} × ${formatPercent(strategyForecastSettings.conversionRate)} конверсии = ${formatForecastRange( + selectedStrategyScenario.leadsMin, + selectedStrategyScenario.leadsMax, + " лидов/мес" + )}` + ] + : []; + const strategyDemandLedgerRows: StrategyDemandLedgerRow[] = [ + { + detail: "очищенный пул спроса, который используется как база решения", + label: "Проверенный спрос", + tone: "neutral", + value: formatCount(strategyTotalAnalyzedDemand) + }, + { + detail: strategyPrimaryTargetPath ?? "посадочная не выбрана", + label: "В текущий план", + tone: "good", + value: formatCount(strategySelectedDemand) + }, + { + detail: "очередь, статьи, широкий интент или ручная проверка", + label: "Отложено", + tone: strategyDeferredDemand > 0 ? "warning" : "neutral", + value: formatCount(strategyDeferredDemand) + }, + { + detail: + strategyExcludedRows.length > 0 + ? `${strategyExcludedRows.length} фраз исключены из стратегии` + : "нет исключённых фраз с частотностью", + label: "Исключено", + tone: strategyExcludedRows.length > 0 ? "problem" : "neutral", + value: + strategyExcludedDemandFromRows > 0 + ? formatCount(strategyExcludedDemandFromRows) + : strategyExcludedRows.length > 0 + ? `${strategyExcludedRows.length} фраз` + : "0" + }, + { + detail: "не используется для прогноза: может содержать пересечения, широкие фразы и вторичные роли", + label: "Сырая сумма по посадочной", + tone: strategyRawPortfolioDemand > strategySelectedDemand ? "warning" : "neutral", + value: formatCount(strategyRawPortfolioDemand) + } + ]; + const strategyMarketDifficultyScore = clampChartValue( + 38 + + (serpInterpretation?.readiness.mismatchCount ?? 0) * 8 + + (serpInterpretation?.readiness.competitorGapCount ?? 0) * 4 + + (seoStrategy?.siteMaturity.signals.hasIndexEvidence ? 0 : 14), + 0, + 100 + ); + const strategyMarketDifficultyLevel: StrategyDecisionLevel = + strategyMarketDifficultyScore >= 70 ? "high" : strategyMarketDifficultyScore >= 45 ? "medium" : "low"; + const strategySelectedMismatchCount = strategySelectedKeywordRows.filter( + (row) => row.targetFit === "mismatch" || row.intent === "informational" + ).length; + const strategySelectedFitCount = strategySelectedKeywordRows.filter( + (row) => row.targetFit === "strong_fit" || row.targetFit === "partial_fit" + ).length; + const strategySerpPhraseRows = serpInterpretation?.phraseInterpretations ?? []; + const strategySerpInterpretedCount = + serpInterpretation?.readiness.interpretedPhraseCount ?? strategySerpPhraseRows.length; + const strategySerpCommercialCount = strategySerpPhraseRows.filter((row) => row.intent === "commercial").length; + const strategySerpMixedCount = strategySerpPhraseRows.filter((row) => row.intent === "mixed").length; + const strategySerpInfoCount = strategySerpPhraseRows.filter((row) => row.intent === "informational").length; + const strategySerpUnknownCount = strategySerpPhraseRows.filter((row) => row.intent === "unknown").length; + const strategySerpQueueCount = strategyArticleOrDeferredRows.filter( + (row) => row.role === "article" || row.role === "defer" || row.intent === "informational" || row.intent === "mixed" + ).length; + const strategySerpSummaryRows: StrategyDemandLedgerRow[] = [ + { + detail: + serpInterpretation?.state === "ready" + ? `${strategySelectedFitCount} совпадений по выбранным фразам` + : "нужно интерпретировать выдачу", + label: "Коммерческий слой", + tone: strategySelectedFitCount > 0 ? "good" : "warning", + value: strategySelectedFitCount > 0 ? "подтверждён" : "нужна проверка" + }, + { + detail: + strategySerpQueueCount > 0 + ? `${formatPhraseCount(strategySerpQueueCount)} не идут в основную ставку` + : "широкий слой пока не выделен", + label: "Смешанный / инфо-интент", + tone: strategySerpQueueCount > 0 ? "warning" : "neutral", + value: strategySerpQueueCount > 0 ? "в очередь" : "нет явного риска" + }, + { + detail: "смотрим не сырые домены, а пробелы для структуры страницы", + label: "Конкуренты", + tone: (serpInterpretation?.readiness.competitorGapCount ?? 0) > 0 ? "warning" : "neutral", + value: serpInterpretation ? `${serpInterpretation.readiness.competitorGapCount} пробелов` : "нет данных" + }, + { + detail: + strategyPrimaryTargetPath && strategySerpQueueCount > 0 + ? `${strategyPrimaryTargetPath} ведём как коммерческую посадочную, широкий слой выносим отдельно` + : "маршрут зависит от посадочной и типа выдачи", + label: "Вывод", + tone: strategyPrimaryTargetPath ? "good" : "warning", + value: strategyPrimaryTargetPath ? "можно планировать" : "нужен URL" + } + ]; + const strategySerpTypeRows: StrategyDemandLedgerRow[] = [ + { + detail: "фразы, где выдача похожа на коммерческую посадочную", + label: "Коммерческий интент", + tone: strategySerpCommercialCount > 0 ? "good" : "neutral", + value: formatPhraseCount(strategySerpCommercialCount) + }, + { + detail: "фразы, где посадочная может пересекаться со статьями или справкой", + label: "Смешанный интент", + tone: strategySerpMixedCount > 0 ? "warning" : "neutral", + value: formatPhraseCount(strategySerpMixedCount) + }, + { + detail: "фразы, которые нельзя делать основной ставкой посадочной без отдельного URL", + label: "Инфо-интент", + tone: strategySerpInfoCount > 0 ? "warning" : "neutral", + value: formatPhraseCount(strategySerpInfoCount) + }, + { + detail: "фразы без уверенного типа выдачи", + label: "Неясно", + tone: strategySerpUnknownCount > 0 ? "warning" : "neutral", + value: formatPhraseCount(strategySerpUnknownCount) + } + ]; + const strategyIntentFitValue = + strategySelectedMismatchCount > 0 + ? "смешанный" + : strategySelectedFitCount > 0 + ? "подходит" + : serpInterpretation?.state === "ready" + ? "нужно уточнить" + : "нет данных выдачи"; + const strategyIntentFitTone: SeoChartTone = + strategySelectedMismatchCount > 0 ? "warning" : strategySelectedFitCount > 0 ? "good" : "warning"; + const strategyPlanReadinessLevel: StrategyDecisionLevel = + strategyPrimaryTargetPath && strategyVisibleKeywordRows.length > 0 && strategyClusterRows.length > 0 + ? strategyPrimaryPageId + ? "high" + : "medium" + : "low"; + const strategyDecisionScorecard: StrategyDecisionScorecardItem[] = [ + { + detail: "рынок может быть сложным, но это не равно риску выбранного действия", + label: "Сложность выдачи", + tone: getStrategyRiskTone(strategyMarketDifficultyLevel), + value: getStrategyReadinessLabel(strategyMarketDifficultyLevel) + }, + { + detail: + selectedStrategyScenario?.risk === "high" + ? "широкий слой может смешать интент и позиционирование" + : "риск ниже атаки широкого спроса, потому что выбран ограниченный вход", + label: "Риск действия", + tone: selectedStrategyScenario ? getStrategyRiskTone(selectedStrategyScenario.risk) : "warning", + value: selectedStrategyScenario ? getStrategyRiskLabel(selectedStrategyScenario.risk) : "нужно выбрать" + }, + { + detail: + serpInterpretation?.state === "ready" + ? `совпадений: ${strategySelectedFitCount} · расхождений: ${strategySelectedMismatchCount}` + : "выдача ещё не интерпретирована как фактор решения", + label: "Совпадение интента", + tone: strategyIntentFitTone, + value: strategyIntentFitValue + }, + { + detail: "соответствие продукту и коммерческому действию", + label: "Бизнес-соответствие", + tone: selectedStrategyScenario ? getStrategyPositiveLevelTone(selectedStrategyScenario.businessFit) : "warning", + value: selectedStrategyScenario ? getStrategyBusinessFitLabel(selectedStrategyScenario.businessFit) : "нужно выбрать" + }, + { + detail: "это только связь URL, не готовность переписывания и применения", + label: "Связь страницы", + tone: strategyPrimaryPageId ? "good" : "warning", + value: strategyPrimaryPageStatus + }, + { + detail: "Этап 07 получит вход; переписывание и применение остаются закрыты до проверки", + label: "Готовность к плану", + tone: getStrategyPositiveLevelTone(strategyPlanReadinessLevel), + value: getStrategyReadinessLabel(strategyPlanReadinessLevel) + } + ]; + const buildScenarioComparisonValues = (mapper: (scenario: StrategyDecisionScenario) => string) => + strategyDecisionScenarios.reduce>((values, scenario) => { + values[scenario.id] = mapper(scenario); + return values; + }, {}); + const getStrategyScenarioNeed = (scenario: StrategyDecisionScenario) => { + if (scenario.id === "landing_plus_article") { + return "посадочная + инфо URL"; + } + + if (scenario.id === "homepage_only") { + return "только текущие страницы"; + } + + if (scenario.id === "broad_attack") { + return "ручное решение по широкому спросу"; + } + + return strategyPrimaryTargetPath ? `план ${strategyPrimaryTargetPath}` : "выбрать посадочную"; + }; + const strategyScenarioComparisonRows = [ + { + label: "Спрос в работе", + values: buildScenarioComparisonValues((scenario) => formatCount(scenario.demand)) + }, + { + label: "Риск интента", + values: buildScenarioComparisonValues((scenario) => getStrategyRiskLabel(scenario.risk)) + }, + { + label: "Усилие", + values: buildScenarioComparisonValues((scenario) => getStrategyEffortLabel(scenario.effort)) + }, + { + label: "Бизнес-соответствие", + values: buildScenarioComparisonValues((scenario) => getStrategyBusinessFitLabel(scenario.businessFit)) + }, + { + label: "Готовность", + values: buildScenarioComparisonValues((scenario) => getStrategyReadinessLabel(scenario.readiness)) + }, + { + label: "Что нужно", + values: buildScenarioComparisonValues(getStrategyScenarioNeed) + }, + { + label: "Итог", + values: buildScenarioComparisonValues((scenario) => scenario.outcome) + } + ]; + const strategyWhySelected = + selectedStrategyScenario?.id === "landing_first" + ? `A выбран, потому что даёт баланс: ${formatCount( + strategySelectedDemand + )} спроса, отдельная посадочная, высокое бизнес-соответствие и меньше риска, чем атака широкого спроса.` + : selectedStrategyScenario + ? `${selectedStrategyScenario.label} выбран локально: прогноз и матрица пересчитаны, но это ещё не сохранённый серверный контракт.` + : "Сценарий не выбран."; + const strategyScenarioDiffRows: StrategyDemandLedgerRow[] = + selectedStrategyScenario && strategyRecommendedScenario + ? [ + { + detail: `${formatCount(strategyRecommendedScenario.demand)} → ${formatCount( + selectedStrategyScenario.demand + )}`, + label: "Спрос против A", + tone: + selectedStrategyScenario.demand > strategyRecommendedScenario.demand + ? "good" + : selectedStrategyScenario.demand < strategyRecommendedScenario.demand + ? "warning" + : "neutral", + value: + selectedStrategyScenario.id === strategyRecommendedScenario.id + ? "базовый сценарий" + : formatSignedCount(selectedStrategyScenario.demand - strategyRecommendedScenario.demand) + }, + { + detail: `${formatForecastRange( + strategyRecommendedScenario.trafficMin, + strategyRecommendedScenario.trafficMax, + " визитов/мес" + )} → ${formatForecastRange( + selectedStrategyScenario.trafficMin, + selectedStrategyScenario.trafficMax, + " визитов/мес" + )}`, + label: "Визиты против A", + tone: + selectedStrategyScenario.trafficMax > strategyRecommendedScenario.trafficMax + ? "good" + : selectedStrategyScenario.trafficMax < strategyRecommendedScenario.trafficMax + ? "warning" + : "neutral", + value: + selectedStrategyScenario.id === strategyRecommendedScenario.id + ? "без изменения" + : formatSignedForecastRange( + selectedStrategyScenario.trafficMin - strategyRecommendedScenario.trafficMin, + selectedStrategyScenario.trafficMax - strategyRecommendedScenario.trafficMax, + " визитов/мес" + ) + }, + { + detail: `${getStrategyEffortLabel(strategyRecommendedScenario.effort)} → ${getStrategyEffortLabel( + selectedStrategyScenario.effort + )}`, + label: "Усилие", + tone: selectedStrategyScenario.effort === "high" ? "warning" : "neutral", + value: + selectedStrategyScenario.id === strategyRecommendedScenario.id + ? "база" + : getStrategyEffortLabel(selectedStrategyScenario.effort) + }, + { + detail: `${getStrategyRiskLabel(strategyRecommendedScenario.risk)} → ${getStrategyRiskLabel( + selectedStrategyScenario.risk + )}`, + label: "Риск", + tone: getStrategyRiskTone(selectedStrategyScenario.risk), + value: + selectedStrategyScenario.id === strategyRecommendedScenario.id + ? "база" + : getStrategyRiskLabel(selectedStrategyScenario.risk) + }, + { + detail: selectedStrategyScenario.when, + label: "Что нужно", + tone: selectedStrategyScenario.id === "broad_attack" ? "problem" : "neutral", + value: + selectedStrategyScenario.id === "landing_plus_article" + ? "отдельный справочный URL" + : selectedStrategyScenario.id === "homepage_only" + ? "работать только с текущими страницами" + : selectedStrategyScenario.id === "broad_attack" + ? "ручное подтверждение широкого интента" + : strategyPrimaryTargetPath + ? `план для ${strategyPrimaryTargetPath}` + : "выбрать посадочную" + } + ] + : []; + const strategyNotPrimaryPhrases = Array.from( + new Set(strategyArticleOrDeferredRows.map((row) => row.phrase).filter(Boolean)) + ).slice(0, 4); + const strategyExactPhraseExamples = + strategyNotPrimaryPhrases.length > 0 ? strategyNotPrimaryPhrases.map((phrase) => `«${phrase}»`).join(", ") : ""; + const strategyClusterPhraseSplitRows: StrategyDemandLedgerRow[] = [ + { + detail: strategyPrimaryTargetPath + ? `ведём на ${strategyPrimaryTargetPath}` + : "без URL кластер нельзя отдавать в план", + label: "Кластер", + tone: strategyPrimaryCluster !== "кластер не выбран" ? "good" : "warning", + value: strategyPrimaryCluster + }, + { + detail: + strategyExactPhraseExamples || + "нет широких или информационных примеров в текущем видимом слое, но правило остаётся активным", + label: "Точные фразы", + tone: strategyNotPrimaryPhrases.length > 0 ? "warning" : "neutral", + value: strategyNotPrimaryPhrases.length > 0 ? "не основная ставка" : "нет спорных примеров" + }, + { + detail: "коммерческое ядро идёт в посадочную, широкий слой уходит в статью, очередь или ручную проверку", + label: "Правило", + tone: "warning", + value: "кластер не равен точной фразе" + } + ]; + const strategyDecisionContract: StrategyDecisionContract | null = selectedStrategyScenario + ? { + conversionRate: strategyForecastSettings.conversionRate, + deferredDemand: strategyDeferredDemand, + forecastModel: strategyForecastSettings.ctrModel, + horizonMonths: strategyForecastSettings.horizonMonths, + landing: strategyPrimaryTargetPath, + nextStage: strategyPrimaryTargetPath ? `план правок для ${strategyPrimaryTargetPath}` : "выбрать посадочную", + notPrimaryPhrases: strategyNotPrimaryPhrases, + primaryCluster: strategyPrimaryCluster, + scenarioId: selectedStrategyScenario.id, + scenarioLabel: selectedStrategyScenario.label, + selectedDemand: strategySelectedDemand, + supportClusters: [strategyDifferentiatorCluster, ...strategySecondaryClusters] + .filter((cluster): cluster is string => Boolean(cluster)) + .filter((cluster, index, clusters) => clusters.indexOf(cluster) === index) + .slice(0, 4), + visibilityMax: strategyCtrRange.max, + visibilityMin: strategyCtrRange.min + } + : null; + const isStrategyDecisionConfirmOpen = pendingStrategyDecisionProjectId === project.id; + const strategyDecisionCompactSummary = strategyDecisionContract + ? `${strategyDecisionContract.scenarioLabel} · ${strategyDecisionContract.landing ?? "URL не выбран"} · ${formatCount( + strategyDecisionContract.selectedDemand + )} спроса · широкий слой в очередь · ${STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label}` + : "Сначала нужно выбрать сценарий и посадочную."; + const strategyPositioningRows = [ + { + detail: strategyPrimaryTargetPath ? `ведём на ${strategyPrimaryTargetPath}` : "сначала нужен URL посадочной", + label: "Основной кластер", + value: strategyPrimaryCluster + }, + { + detail: "идут в секции, FAQ, внутренние ссылки и поддерживающий текст", + label: "Поддерживающие кластеры", + value: strategySecondaryClusters.length > 0 ? strategySecondaryClusters.join(" · ") : "нужно добрать" + }, + { + detail: "усиливает страницу, но не меняет основную ставку", + label: "Отличающий слой", + value: strategyDifferentiatorCluster ?? "не выбран" + }, + { + detail: "точные широкие фразы не становятся основной ставкой без отдельного интента", + label: "Не делаем основной ставкой", + value: strategyNotPrimaryPhrases.length > 0 ? strategyNotPrimaryPhrases.join(" · ") : "нет спорных фраз в видимом слое" + } + ]; + const strategySerpDecisionRows = [ + { + detail: + serpInterpretation?.state === "ready" + ? `совпадений по выбранным фразам: ${strategySelectedFitCount}` + : "нужно собрать и интерпретировать выдачу", + label: "Выбранная посадочная", + value: + strategySelectedFitCount > 0 + ? "можно вести коммерческим слоем" + : serpInterpretation?.state === "ready" + ? "нужна ручная проверка" + : "выдача ещё не фактор решения" + }, + { + detail: + strategyArticleOrDeferredRows.length > 0 + ? `${strategyArticleOrDeferredRows.length} фраз уходит в очередь или статьи` + : "широкий слой пока не выделен", + label: "Широкий слой", + value: strategyArticleOrDeferredRows.length > 0 ? "не делать основной ставкой" : "нет явного риска широкого интента" + }, + { + detail: + formatStrategyDisplayText(serpInterpretation?.competitorGaps[0]?.strategyImplication ?? "") || + "нужны типы страниц, сниппеты и конкурентные блоки", + label: "Конкуренты", + value: serpInterpretation ? `${serpInterpretation.readiness.competitorGapCount} пробелов` : "нет данных" + } + ]; + const strategyCompetitorContentRows = + serpInterpretation?.competitorGaps.slice(0, 4).map((gap) => ({ + detail: formatStrategyDisplayText(gap.strategyImplication), + label: gap.domain, + value: formatStrategyDisplayText(gap.observedAngle || gap.sampleTitle) + })) ?? []; + const strategyLandingPortfolioLanes: StrategyLandingPortfolioLane[] = [ + { + id: "now", + label: "Сейчас", + rows: strategyLandingDecisionRows.filter((row) => row.targetPath === strategyPrimaryTargetPath) + }, + { + id: "next", + label: "Следом", + rows: strategyLandingDecisionRows.filter( + (row) => + row.targetPath !== strategyPrimaryTargetPath && + row.targetPath !== "/" && + row.action === "strengthen" && + row.demand > 0 + ) + }, + { + id: "backlog", + label: "В очередь", + rows: strategyLandingDecisionRows.filter((row) => row.action === "defer" || row.targetPath === "URL не выбран") + }, + { + id: "hold", + label: "Не трогаем", + rows: strategyLandingDecisionRows.filter((row) => row.targetPath === "/" || row.action === "keep") + } + ].filter((lane) => lane.rows.length > 0); + const strategyDecisionMetrics = [ + { + detail: strategyPrimaryCluster, + label: "Посадочная", + value: strategyPrimaryTargetPath ?? "нужен URL" + }, + { + detail: "проверенный пул Wordstat и выдачи", + label: "Спрос в анализе", + value: formatCount(strategyTotalAnalyzedDemand) + }, + { + detail: strategyPrimaryTargetPath ?? "страница не выбрана", + label: "В работу", + value: formatCount(strategySelectedDemand) + }, + { + detail: `${formatPercent(strategyCtrRange.min)}–${formatPercent(strategyCtrRange.max)} видимость · ${formatPercent( + strategyForecastSettings.conversionRate + )} конверсия`, + label: "Визиты/мес", + value: selectedStrategyScenario + ? formatForecastRange(selectedStrategyScenario.trafficMin, selectedStrategyScenario.trafficMax) + : "n/a" + }, + { + detail: `горизонт ${strategyForecastSettings.horizonMonths} мес. · не обещание результата`, + label: "Лиды/мес", + value: selectedStrategyScenario + ? formatForecastRange(selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMax) + : "n/a" + } + ]; + const strategyCanAcceptDecision = Boolean( + seoStrategy && + seoStrategy.state !== "blocked" && + strategyPrimaryTargetPath && + strategyVisibleKeywordRows.length > 0 && + strategyClusterRows.length > 0 + ); const isExportingSemantic = exportingSemanticKey?.startsWith(`${project.id}:`) ?? false; const isSelectingPages = selectingPagesProjectId === project.id; const selectedPagesCount = scanSummary ? getSelectedPagesCount(scanSummary) : 0; @@ -7387,6 +10392,8 @@ export function App() { {scanSummary && activeStageIndex !== 1 ? (
+ {activeStageIndex === 5 ? null : ( + <>
@@ -7412,6 +10419,8 @@ export function App() { {scanSummary.projectIndexKey} ) : null} + + )} {activeStageIndex === 3 ? (
@@ -7566,16 +10575,16 @@ export function App() { {activeContextReview ? `${formatDate(activeContextReview.generatedAt)} · ${activeContextReview.summary}` - : "Сохранённый review нужен перед рыночным спросом: forbidden claims, ambiguity и normalization hints."} + : "Сохранённая проверка нужна перед рыночным спросом: запретные обещания, неоднозначности и подсказки нормализации."}
- {activeContextReview?.provider.mode === "codex_manual" ? "Codex" : "Fallback"} - {activeContextReview?.analystReview.status ?? "not_reviewed"} - Hints {activeContextReview?.readiness.normalizationHintCount ?? 0} - Forbidden {activeContextReview?.readiness.forbiddenClaimCount ?? 0} - Gaps {activeContextReview?.readiness.semanticGapCount ?? 0} - Review {activeContextReview?.readiness.needsHumanReview ? "yes" : "no"} + {activeContextReview?.provider.mode === "codex_manual" ? "модель" : "резерв"} + {activeContextReview?.analystReview.status ?? "не проверено"} + Подсказки {activeContextReview?.readiness.normalizationHintCount ?? 0} + Запреты {activeContextReview?.readiness.forbiddenClaimCount ?? 0} + Пробелы {activeContextReview?.readiness.semanticGapCount ?? 0} + Проверка {activeContextReview?.readiness.needsHumanReview ? "да" : "нет"}
@@ -8345,7 +11354,7 @@ export function App() { )}
) : null} - {stageDecisionFrame ? ( + {stageDecisionFrame && activeStageIndex !== 5 ? (
Решение этапа
@@ -8457,22 +11466,22 @@ export function App() { SEO-нормализация {marketEnrichment.normalization.summary} - Сырых seeds: {marketEnrichment.normalization.readiness.sourceSeedCount} · нормализовано:{" "} - {marketEnrichment.normalization.readiness.normalizedSeedCount} · intent groups:{" "} - {marketEnrichment.normalization.readiness.intentGroupCount} · review:{" "} + Сырых фраз: {marketEnrichment.normalization.readiness.sourceSeedCount} · нормализовано:{" "} + {marketEnrichment.normalization.readiness.normalizedSeedCount} · групп интента:{" "} + {marketEnrichment.normalization.readiness.intentGroupCount} · ручная проверка:{" "} {marketEnrichment.normalization.readiness.needsHumanReviewCount}
- {marketEnrichment.normalization.provider.mode === "codex_manual" ? "Codex" : "Fallback"} + {marketEnrichment.normalization.provider.mode === "codex_manual" ? "модель" : "резерв"} {marketEnrichment.normalization.analystReview.status} - Auto {marketEnrichment.normalization.readiness.autoApprovedCount} - Wordstat {marketEnrichment.normalization.readiness.wordstatApprovedCount} - SERP {marketEnrichment.normalization.readiness.serpApprovedCount} - Rejected {marketEnrichment.normalization.readiness.rejectedSeedCount} - - {marketEnrichment.normalization.modelTask ? "Model task ready" : "Model task waiting"} + Авто {marketEnrichment.normalization.readiness.autoApprovedCount} + Wordstat {marketEnrichment.normalization.readiness.wordstatApprovedCount} + Выдача {marketEnrichment.normalization.readiness.serpApprovedCount} + Отклонено {marketEnrichment.normalization.readiness.rejectedSeedCount} + + {marketEnrichment.normalization.modelTask ? "задача модели готова" : "задача модели ждёт"}
@@ -8487,7 +11496,7 @@ export function App() {
{marketEnrichment.normalization.rejectedSeeds.length > 0 ? (

- Отсечено широких/мусорных seeds: {marketEnrichment.normalization.rejectedSeeds.length}.{" "} + Отсечено широких или мусорных фраз: {marketEnrichment.normalization.rejectedSeeds.length}.{" "} {marketEnrichment.normalization.rejectedSeeds .slice(0, 3) .map((seed) => seed.phrase) @@ -8501,14 +11510,14 @@ export function App() { @@ -8528,23 +11537,23 @@ export function App() {

- Сигналы approved queue + Сигналы подтверждённой очереди {marketEnrichment.wordstat.latestJob ? `${marketEnrichment.wordstat.latestJob.status} · ${marketEnrichment.wordstat.latestJob.requestedPhraseCount} фраз` - : "job ещё не создавался"} + : "задача ещё не создавалась"}
@@ -8585,20 +11594,20 @@ export function App() { <>
- Keyword cleaning + Очистка фраз {keywordCleaning.summary} - После Wordstat/SERP: что можно пустить в SEO-план, что оставить как backlog, что + После Wordstat и разбора выдачи: что можно пустить в SEO-план, что оставить в запасе, что проверить и что выкинуть.
- {keywordCleaning.provider.mode === "codex_manual" ? "Codex" : "Fallback"} + {keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"} {keywordCleaning.analystReview.status} - Use {keywordCleaning.readiness.useCount} - Support {keywordCleaning.readiness.supportCount} - Article {keywordCleaning.readiness.articleCount} - Risky {keywordCleaning.readiness.riskyCount} - Trash {keywordCleaning.readiness.trashCount} + Главные {keywordCleaning.readiness.useCount} + Поддержка {keywordCleaning.readiness.supportCount} + Контент {keywordCleaning.readiness.articleCount} + Риск {keywordCleaning.readiness.riskyCount} + Мусор {keywordCleaning.readiness.trashCount}
0 ? (
- Raw Wordstat rows + Сырые строки Wordstat
{marketEnrichment.wordstat.topResults.slice(0, 10).map((result) => (

@@ -8740,11 +11749,11 @@ export function App() { )} ) : null} - {activeStageIndex === 5 ? ( + {activeStageIndex === 6 ? (

- SEO-план страниц + План правок Что улучшать на страницах: фраза, поле, роль, лимит и причина решения.
+ {acceptedStrategyDecisionContract ? ( +
+
+ Вход из стратегии + + {acceptedStrategyDecisionContract.scenarioLabel} ·{" "} + {acceptedStrategyDecisionContract.landing ?? "URL не выбран"} ·{" "} + {formatCount(acceptedStrategyDecisionContract.selectedDemand)} спроса + + + Основной кластер: {acceptedStrategyDecisionContract.primaryCluster} · отложено{" "} + {formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "} + {STRATEGY_CTR_RANGES[acceptedStrategyDecisionContract.forecastModel].label} + +
+
+ Не отдавать в title + + {acceptedStrategyDecisionContract.notPrimaryPhrases.length > 0 + ? acceptedStrategyDecisionContract.notPrimaryPhrases.slice(0, 3).join(" · ") + : "широкие точные фразы"} + + они идут в статью, очередь или ручную проверку +
+
+ ) : null} + {keywordMap ? ( <> {keywordPlanDecision ? ( @@ -8771,10 +11807,10 @@ export function App() { {keywordPlanDecision.label} {keywordPlanDecision.title}

{keywordPlanDecision.summary}

-

{keywordPlanDecision.action}

- - keyword-map.v1 · {getKeywordMapStateLabel(keywordMap.state)} · ontology{" "} - {keywordMap.projectOntologyVersion +

{keywordPlanDecision.action}

+ + карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "} + {keywordMap.projectOntologyVersion ? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel( keywordMap.projectOntologyVersion.approvalStatus )}` @@ -8795,26 +11831,26 @@ export function App() {
- Источник SEO-плана - - {keywordMap.cleaning.schemaVersion} ·{" "} - {keywordMap.cleaning.providerMode === "codex_manual" ? "Codex" : "Fallback"} ·{" "} - {keywordMap.cleaning.analystStatus} - + Источник плана правок + + очистка фраз ·{" "} + {keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "} + {keywordMap.cleaning.analystStatus} +
- use {keywordMap.cleaning.useCount} - support {keywordMap.cleaning.supportCount} - article {keywordMap.cleaning.articleCount} - risky {keywordMap.cleaning.riskyCount} - trash {keywordMap.cleaning.trashCount} + главные {keywordMap.cleaning.useCount} + поддержка {keywordMap.cleaning.supportCount} + контент {keywordMap.cleaning.articleCount} + риск {keywordMap.cleaning.riskyCount} + мусор {keywordMap.cleaning.trashCount}
Аналитическая база перед стратегией

- Сайт не меняем. Этот этап должен доказать ценность анализа: контекст, спрос, evidence, + Сайт не меняем. Этот этап должен доказать ценность анализа: контекст, спрос, доказательства, риски и понятные стратегические варианты.

Уже есть: {keywordMap.persisted.approvedDecisionCount} подтверждённых решений,{" "} - {keywordMap.readiness.marketEvidenceCount} фраз с market evidence,{" "} + {keywordMap.readiness.marketEvidenceCount} фраз с рыночными доказательствами,{" "} {keywordMap.cleaning.trashCount} мусорных фраз отфильтровано.

- Не хватает: SERP/competitor evidence, Yandex Webmaster/Метрика/Search Console слой, + Не хватает: доказательств выдачи и конкурентов, Yandex Webmaster/Метрика/Search Console слой, прогнозы трафика и понятная карта возможностей.

@@ -8944,8 +11980,8 @@ export function App() {

- Что нельзя пускать в rewrite - Фразы без безопасного exact evidence, page binding или с ролью validate. + Что нельзя пускать в переписывание + Фразы без безопасного точного доказательства, привязки к странице или с ролью ручной проверки.
{keywordReviewItems.slice(0, 8).map((item) => ( @@ -8971,7 +12007,7 @@ export function App() {
Следующее действие - Что нужно сделать перед stage 07 стратегией. + Что нужно сделать после выбора стратегии и посадочной.
{keywordMap.nextActions.map((action) => ( @@ -8993,9 +12029,9 @@ export function App() { {keywordMap.items.length > 0 ? (
- DEV DETAILS - Raw keyword-map items - Сырой список фраз, ролей, страниц и evidence для проверки пайплайна. + Технические детали + Сырые элементы карты фраз + Сырой список фраз, ролей, страниц и доказательств для проверки пайплайна.
{keywordMap.items.map((item) => ( @@ -9016,161 +12052,831 @@ export function App() { ) : (
- Сначала нужны semantic analysis и рыночный спрос: SEO-план появится как draft поверх - reviewed ontology и evidence. + Сначала нужны семантический анализ и рыночный спрос: план правок появится как черновик поверх + проверенной онтологии и доказательств.
)}
) : null} - {activeStageIndex === 6 ? ( + {activeStageIndex === 5 ? (
- SEO-стратегия + Стратегия и карта посадочных - Read-only слой: доказываем возможности роста, риски и недостающие данные. Сайт на этом этапе не - меняем. + Выбираем кластеры, страницы и правила для следующего плана правок. Сайт на этом этапе не меняем.
-
-
- yandex-evidence.v1 - - {yandexEvidence - ? getYandexEvidenceStateLabel(yandexEvidence.state) - : "внешний evidence ещё не загружен"} - -

- Wordstat top/regions/dynamics и SERP конкурентов собираются отдельным read-only run. Это источник - фактов для стратегии, а не команда на rewrite. -

-
- - - -
-
-
-
- Фразы - {yandexEvidence?.summary.checkedPhraseCount ?? 0} - проверено -
-
- Спрос - {formatCount(yandexEvidence?.summary.totalDemand ?? 0)} - Wordstat total -
-
- SERP - {yandexEvidence?.summary.serpDocCount ?? 0} - docs -
-
- Домены - {yandexEvidence?.summary.competitorDomainCount ?? 0} - competitors -
-
-
- -
-
- seo-serp-interpretation.v1 - - {serpInterpretation?.state === "ready" - ? "интерпретация собрана" - : "SERP facts ещё не интерпретированы"} - -

- {serpInterpretation?.state === "ready" - ? serpInterpretation.summary - : "Нужен отдельный слой, который объяснит target-fit, mismatch, конкурентные углы и риски. Сырые домены сами по себе не являются стратегией."} -

-
-
-
- Фразы - {serpInterpretation?.readiness.interpretedPhraseCount ?? 0} - interpreted -
-
- Mismatch - {serpInterpretation?.readiness.mismatchCount ?? 0} - intent fit -
-
- Gaps - {serpInterpretation?.readiness.competitorGapCount ?? 0} - competitors -
-
- Риски - {serpInterpretation?.readiness.riskCount ?? 0} - до approval -
-
-
- {seoStrategy ? ( <> -
-
- {getSeoStrategyStateLabel(seoStrategy.state)} - {seoStrategy.verdict.title} +
+
+
+ {selectedStrategyScenario?.isRecommended ? "Рекомендованный сценарий" : "Выбранный сценарий"} +
+ + + + +
+
+
+ + {strategyPrimaryTargetPath + ? `Решение: ${strategyPrimaryTargetPath} закрывает «${strategyPrimaryCluster}»` + : "Нужно выбрать посадочную перед планом правок"} + +

+ {strategyPrimaryTargetPath + ? `Берём в первый план ${formatCount(strategySelectedDemand)} из ${formatCount( + strategyTotalAnalyzedDemand + )} спроса. Не берём сейчас ${formatCount( + strategyDeferredDemand + )}: широкий спрос, статьи, спорный интент или ручная проверка.` + : `Кластер «${strategyPrimaryCluster}» есть, но URL посадочной ещё не подтверждён. Сначала нужна посадочная, потом план правок.`} +

+ + Берём кластер, но не делаем широкую точную фразу главным ключом. + + {strategyWhySelected} + + {selectedStrategyScenario + ? `Сценарный прогноз: ${formatForecastRange( + selectedStrategyScenario.trafficMin, + selectedStrategyScenario.trafficMax, + " визитов/мес" + )}, ${formatForecastRange( + selectedStrategyScenario.leadsMin, + selectedStrategyScenario.leadsMax, + " лидов/мес" + )}. ` + : ""} + Следующий артефакт:{" "} + {strategyPrimaryPageId ? "план правок" : "связать или создать посадочную"} для{" "} + {strategyPrimaryTargetPath ?? "выбранной посадочной"}. + +
+
+
+ {strategyDecisionMetrics.map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+
+
+ + {isStrategyDecisionConfirmOpen && strategyDecisionContract ? ( +
+
+
+
+ Фиксация решения + Подтвердить контракт для этапа 07 +
+ +
+
+
+ Сценарий + {strategyDecisionContract.scenarioLabel} + {selectedStrategyScenario?.title} +
+
+ Посадочная + {strategyDecisionContract.landing ?? "URL не выбран"} + {strategyDecisionContract.primaryCluster} +
+
+ Спрос + {formatCount(strategyDecisionContract.selectedDemand)} + отложено {formatCount(strategyDecisionContract.deferredDemand)} +
+
+ Прогноз + {STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label} + + {formatPercent(strategyDecisionContract.visibilityMin)}–{formatPercent(strategyDecisionContract.visibilityMax)} ·{" "} + {formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.horizonMonths} мес. + +
+
+ Поддержка + + {strategyDecisionContract.supportClusters.length > 0 + ? strategyDecisionContract.supportClusters.join(" · ") + : "не выбрана"} + + идёт в секции и описание, не в перегруженный title +
+
+ Не основная ставка + + {strategyDecisionContract.notPrimaryPhrases.length > 0 + ? strategyDecisionContract.notPrimaryPhrases.slice(0, 3).join(" · ") + : "широкие точные фразы"} + + статья, очередь или ручная проверка +
+
+
+ + +
+
+
+ ) : null} + +
+ + Контракт решения + После фиксации в этап 07 уйдёт: {strategyDecisionCompactSummary} + + {acceptedStrategyDecisionContract + ? `локально зафиксировано: ${acceptedStrategyDecisionContract.scenarioLabel}` + : "сайт не меняется, фиксируется только выбранный маршрут"} + + +
+ {strategyDecisionSummaryRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ +
+
+
+ Учёт спроса + Каким числам верить в прогнозе и портфеле +
+ прогноз считается только от “в текущий план” +
+
+ {strategyDemandLedgerRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ +
+
+
+ Сценарии + Выбор стратегии со спросом, прогнозом, риском и усилием +
+ {strategyDecisionScenarios.length} варианта +
+
+ {strategyDecisionScenarios.map((scenario) => ( +
+
+ {scenario.label} + {scenario.title} +
+
+ спрос {formatCount(scenario.demand)} + {formatForecastRange(scenario.trafficMin, scenario.trafficMax, " визитов/мес")} + {formatForecastRange(scenario.leadsMin, scenario.leadsMax, " лидов/мес")} +
+

{scenario.why}

+
+ риск {getStrategyRiskLabel(scenario.risk)} + усилие {getStrategyEffortLabel(scenario.effort)} + {scenario.pages.join(" + ")} +
+ {scenario.when} + +
+ ))} +
+
+
+ Почему выбран сценарий + {strategyWhySelected} +
+
+ + + + + {strategyDecisionScenarios.map((scenario) => ( + + ))} + + + + {strategyScenarioComparisonRows.map((row) => ( + + + {strategyDecisionScenarios.map((scenario) => ( + + ))} + + ))} + +
Критерий{scenario.label}
{row.label} + {row.values[scenario.id]} +
+
+
+ Разница выбранного сценария относительно A +
+ {strategyScenarioDiffRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+
+
+ +
+
+
+ Экономика решения + Спрос → охват → сценарный трафик → лиды +
+ это модель, не обещание результата +
+
+ + +
+ Формула прогноза + {strategyForecastFormulaRows.map((formula) => ( + {formula} + ))} + Это простая сценарная модель. Она не обещает позиции, трафик или лиды. +
+
+ {strategyDecisionScorecard.map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+
+
+ +
+
+
+ Настройки прогноза + Можно проверить осторожную, базовую и оптимистичную модель +
+ + {STRATEGY_CTR_RANGES[strategyForecastSettings.ctrModel].label} · конверсия{" "} + {formatPercent(strategyForecastSettings.conversionRate)} ·{" "} + {strategyForecastSettings.horizonMonths} мес. + +
+
+
+ Модель видимости + {(["safe", "base", "optimistic"] as const).map((model) => ( + + ))} +
+
+ Конверсия + {[0.005, 0.01, 0.02].map((rate) => ( + + ))} +
+
+ Горизонт + {[1, 3, 6].map((months) => ( + + ))} +
+
+ Риск-режим + {(["safe", "balanced", "aggressive"] as const).map((riskMode) => ( + + ))} +
+
+
+ +
+
+
+ Позиционирование страницы + Что является основной ставкой, а что только поддерживает её +
+ {strategyPrimaryTargetPath ?? "URL посадочной не выбран"} +
+
+ {strategyPositioningRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ {strategyClusterPhraseSplitRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ +
+
+
+ Фактор выдачи + Как выдача влияет на выбранный маршрут +
+ + {serpInterpretation?.state === "ready" + ? `Выдача разобрана: ${formatPhraseCount(strategySerpInterpretedCount)} из ${formatPhraseCountGenitive( + strategyVisibleKeywordRows.length + )}; остальные по Wordstat и очистке фраз` + : "нужно добрать доказательства выдачи"} + +
+
+ {strategySerpDecisionRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ {strategySerpSummaryRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ {strategySerpTypeRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ ))} +
+
+ Что проверить для структуры посадочной + {strategyCompetitorContentRows.length > 0 ? ( + strategyCompetitorContentRows.map((row) => ( +
+ {row.label} + {row.value} + {row.detail} +
+ )) + ) : ( +
+ Нет конкурентных пробелов + Нужны доказательства конкурентов из выдачи + После сбора выдачи здесь должны появиться блоки, углы и типы страниц конкурентов. +
+ )} +
+
+ +
+
+
+ Кластеры + Что берём, куда ведём и какой риск держим +
+ {strategyClusterRows.length} решений +
+
+ + + + + + + + + + + + + {strategyClusterRows.length > 0 ? ( + strategyClusterRows.map((row) => { + const evidenceLabel = formatStrategyEvidenceRef(row.evidenceRefs[0]); + + return ( + + + + + + + + + ); + }) + ) : ( + + + + )} + +
КластерСпросИнтентКуда ведёмРешениеПричина и риск
+ {row.clusterTitle} + {row.demand > 0 ? formatCount(row.demand) : "нужно добрать"}{row.intent}{row.targetPath ?? "нужно выбрать посадочную"} + + {getStrategyActionLabel(row.action)} + + + {row.risk} + + {row.reason} + {evidenceLabel ? ` · ${evidenceLabel}` : ""} + +
Нет строк с кластером, действием и доказательством. Нужны спрос и карта фраз.
+
+
+ +
+
+
+ Фразы + Какие фразы идут в посадочную, а какие откладываются +
+ + {formatPhraseCount(strategyVisibleKeywordRows.length)} · {formatGroupCount(strategyKeywordRoleGroups.length)} + +
+
+ + + + + + + + + + + + {strategyKeywordRoleGroups.length > 0 ? ( + strategyKeywordRoleGroups.map((group) => { + const phraseGroupKey = `${project.id}:${group.id}`; + const isPhraseGroupExpanded = expandedStrategyPhraseGroups[phraseGroupKey] ?? false; + const visibleGroupRows = isPhraseGroupExpanded ? group.rows : group.rows.slice(0, 5); + const hiddenPhraseCount = Math.max(group.rows.length - visibleGroupRows.length, 0); + + return ( + + + + + {visibleGroupRows.map((row) => { + const evidenceLabel = formatStrategyEvidenceRef(row.evidenceRefs[0]); + const isBacklogKeywordRow = + row.role === "article" || + row.role === "defer" || + row.targetFit === "mismatch" || + row.intent === "informational"; + + return ( + + + + + + + + ); + })} + {group.rows.length > 5 ? ( + + + + ) : null} + + ); + }) + ) : ( + + + + )} + +
ФразаЧастотностьРольСтраница / связьПочему
+ {group.label} + {formatPhraseCount(group.rows.length)} +
+ {row.phrase} + {row.clusterTitle} + {row.frequency !== null ? formatCount(row.frequency) : "нет данных"} + + {getStrategyKeywordRoleLabel(row.role)} + + + {isBacklogKeywordRow ? "URL не выбран" : row.targetPath ?? "нужен URL посадочной"} + {isBacklogKeywordRow && row.targetPath ? ( + связана с {row.targetPath} + ) : null} + + {getStrategyShortReason(row)} +
+ разбор строки + + {row.reason} + {evidenceLabel ? ` · ${evidenceLabel}` : ""} + +
+
+ + {hiddenPhraseCount > 0 ? ( + скрыто {formatPhraseCount(hiddenPhraseCount)} + ) : null} +
Нет фраз с частотностью, ролью или посадочной. Нужно добрать доказательства.
+
+
+ +
+
+
+ Портфель посадочных + Что делаем сейчас, что следом, что уходит в очередь +
+ {strategyTargetPageCount} целевых страниц +
+
+ {strategyLandingPortfolioLanes.map((lane) => ( +
+ {lane.label} + {lane.rows.map((row) => ( +
+
+ + {getStrategyActionLabel(row.action)} + + {row.targetPath} + {getStrategyPageRoleLabel(row.pageRole)} +
+

{row.reason}

+
+ + {row.demand > 0 + ? `связанный спрос ${formatCount(row.demand)}` + : "спрос не выбран в эту ставку"} + + {row.targetPath === strategyPrimaryTargetPath ? strategyPrimaryPageStatus : row.status} +
+
+ {row.targetClusters.map((cluster) => ( + {cluster} + ))} +
+ {row.action === "defer" || row.targetPath === "URL не выбран" ? ( +
+ + + + + +
+ ) : null} +
+ ))} +
+ ))} +
+
+ +
+
+
+ Черновой вход для плана правок + Что этап 07 получит для {strategyPrimaryTargetPath ?? "посадочной"} +
+ это не переписывание и не применение +
+
+ {strategyPlanPreviewRows.map((row) => ( +
+ {row.field} + {row.value} + {row.reason} +
+ ))} +
+
+ +
+
+
+ Запреты + Что нельзя отдавать в план и применение +
+ {formatRuleCount(strategyBlockedMoves.length)} +
+
+ {strategyBlockedMoves.map((move) => ( +
+ {move.title} + {move.reason} +
+ ))} +
+
+ +
+ +
+ + Детали + Доказательства и техническая трасса - Confidence: {getSeoStrategyConfidenceLabel(seoStrategy.verdict.confidence)} · score{" "} + Сырые источники, метрики, фазовые подробности и модельные задачи. Это база проверки, а не готовый маршрут. + + +
+
+
+ {getSeoStrategyStateLabel(seoStrategy.state)} + {seoStrategy.verdict.title} + + Доверие: {getSeoStrategyConfidenceLabel(seoStrategy.verdict.confidence)} · оценка{" "} {seoStrategy.readiness.strategyConfidenceScore}/100 ·{" "} {seoStrategy.projectOntologyVersion?.id - ? `ontology ${seoStrategy.projectOntologyVersion.id.slice(0, 8)}` - : "ontology не подтверждена"} + ? `онтология ${seoStrategy.projectOntologyVersion.id.slice(0, 8)}` + : "онтология не подтверждена"} Главное ограничение: {seoStrategy.verdict.primaryConstraint}
@@ -9179,7 +12885,7 @@ export function App() {
- site-maturity.v1 + Зрелость сайта {getSeoMaturityPhaseLabel(seoStrategy.siteMaturity.phase)}

{seoStrategy.siteMaturity.reason}

@@ -9187,22 +12893,22 @@ export function App() {
Индекс {seoStrategy.siteMaturity.signals.hasIndexEvidence ? "есть" : "нет"} - site evidence + данные по сайту
- Traction + Первые сигналы {seoStrategy.siteMaturity.signals.hasTractionEvidence ? "есть" : "нет"} - impressions/clicks + показы/клики
- SERP + Выдача {seoStrategy.siteMaturity.signals.hasSerpInterpretation ? "OK" : "нет"} - market view + выдача и рынок
- Authority + Авторитет {seoStrategy.siteMaturity.signals.hasAuthorityEvidence ? "есть" : "нет"} - head terms gate + доступ к широким запросам
@@ -9210,16 +12916,16 @@ export function App() {
- выбор стартовой стратегии - - {seoStrategy.siteMaturity.phase === "bootstrap_indexing" - ? "Сайт выглядит как нулевой: начинаем с индексации и long-tail" - : `Рекомендован старт с фазы ${getSeoMaturityPhaseLabel(seoStrategy.siteMaturity.phase)}`} - - - Можно открыть любую фазу и понять, что она даст, но immediate route должен соответствовать - доказанной зрелости сайта. - + выбор стартовой стратегии + + {seoStrategy.siteMaturity.phase === "bootstrap_indexing" + ? "Сайт выглядит как нулевой: начинаем с индексации и точных низкоконкурентных запросов" + : `Рекомендован старт с фазы ${getSeoMaturityPhaseLabel(seoStrategy.siteMaturity.phase)}`} + + + Можно открыть любую фазу и понять, что она даст, но текущий маршрут должен соответствовать + доказанной зрелости сайта. +
@@ -9242,7 +12948,7 @@ export function App() { type="button" > {getSeoPhaseStatusLabel(phase.status)} - {phase.title} + {getSeoMaturityPhaseLabel(phase.phase)} {isCurrent ? "рекомендовано сейчас" : "посмотреть маршрут"} ); @@ -9252,11 +12958,11 @@ export function App() {
{getSeoPhaseStatusLabel(selectedStrategyPhase.status)} - {selectedStrategyPhase.title} + {getSeoMaturityPhaseLabel(selectedStrategyPhase.phase)}

{selectedPhaseStrategy?.summary ?? selectedStrategyPhase.goal}

{selectedPhaseStrategy ? ( - impact {getSeoStrategyPriorityLabel(selectedPhaseStrategy.expectedImpact)} · confidence{" "} + потенциал {getSeoStrategyPriorityLabel(selectedPhaseStrategy.expectedImpact)} · доверие{" "} {getSeoStrategyConfidenceLabel(selectedPhaseStrategy.confidence)} ) : null} @@ -9289,7 +12995,7 @@ export function App() {
- SEO runway · {selectedStrategyPhase?.title ?? "фаза не выбрана"} + Маршрут SEO · {selectedStrategyPhase ? getSeoMaturityPhaseLabel(selectedStrategyPhase.phase) : "фаза не выбрана"} {selectedPhaseStrategy ? selectedPhaseStrategy.goal @@ -9297,24 +13003,24 @@ export function App() {
- {(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase?.allowedTargets ?? []).slice(0, 5).map((action) => ( -
- now - {action} -
- ))} - {(selectedPhaseStrategy?.prepareNext ?? []).slice(0, 4).map((action) => ( -
- prepare - {action} -
- ))} - {(selectedPhaseStrategy?.hold ?? selectedStrategyPhase?.deferredTargets ?? []).slice(0, 4).map((action) => ( -
- hold - {action} -
- ))} + {(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase?.allowedTargets ?? []).slice(0, 5).map((action) => ( +
+ сейчас + {action} +
+ ))} + {(selectedPhaseStrategy?.prepareNext ?? []).slice(0, 4).map((action) => ( +
+ готовить + {action} +
+ ))} + {(selectedPhaseStrategy?.hold ?? selectedStrategyPhase?.deferredTargets ?? []).slice(0, 4).map((action) => ( +
+ стоп + {action} +
+ ))}
@@ -9340,19 +13046,19 @@ export function App() { {item.reason}
))} - {(selectedPhaseStrategy?.nextEvidenceTasks ?? []).slice(0, 3).map((task) => ( -
- evidence - {task} -
- ))} + {(selectedPhaseStrategy?.nextEvidenceTasks ?? []).slice(0, 3).map((task) => ( +
+ данные + {task} +
+ ))}
- strategy.v1 + Доказательная база Понятная база перед любыми изменениями сайта

Сейчас ценность этапа в том, чтобы показать, где спрос уже подтверждён, где аналитика слабая и @@ -9370,7 +13076,7 @@ export function App() { ) : ( )} - Task route + Задача модели

Контекст - {seoStrategy.readiness.contextReady ? "OK" : "Review"} + {seoStrategy.readiness.contextReady ? "есть" : "проверка"} {seoStrategy.evidence.context.status}
0 ? "tone-good" : "tone-warning"}> Возможности {seoStrategy.readiness.opportunityCount} - opportunity groups + группы возможностей
0 ? "tone-warning" : "tone-good"}> - Gaps + Пробелы {seoStrategy.readiness.evidenceGapCount} данных не хватает
0 ? "tone-warning" : "tone-good"}> Риски {seoStrategy.readiness.riskCount} - до approval + до утверждения
@@ -9411,7 +13117,7 @@ export function App() {
@@ -9433,16 +13139,16 @@ export function App() { }`} >
- seo-strategy-synthesis.v1 + Синтез решения {strategySynthesis ? getStrategySynthesisStateLabel(strategySynthesis.state) - : "strategy synthesis не собран"} + : "синтез стратегии не собран"}

{strategySynthesis?.state === "ready" ? strategySynthesis.summary - : "Нужен верхний AI/MCP слой, который превратит evidence, SERP и keyword map в понятные варианты решения, риски и следующий аналитический шаг."} + : "Нужен аналитический слой, который превратит доказательства, выдачу и карту фраз в понятные варианты решения, риски и следующий шаг."}

@@ -9450,8 +13156,8 @@ export function App() { ? getStrategySynthesisMetrics(strategySynthesis) : [ { - detail: "0-100, по evidence stack", - label: "Score", + detail: "0-100, по доказательной базе", + label: "Оценка", tone: "warning" as const, value: 0 }, @@ -9463,7 +13169,7 @@ export function App() { }, { detail: "чего не хватает", - label: "Gaps", + label: "Пробелы", tone: "warning" as const, value: seoStrategy.readiness.evidenceGapCount }, @@ -9492,6 +13198,20 @@ export function App() {

Ограничение: {strategySynthesis.executiveSummary.primaryConstraint}

+
+ + {getStrategySynthesisReviewLabel(strategySynthesis.analystReview.status)} + + Аналитическая проверка + {strategySynthesis.analystReview.notes.length > 0 ? ( + strategySynthesis.analystReview.notes.slice(0, 5).map((note) => ( +

{note}

+ )) + ) : ( +

Результат синтеза ещё не прошёл отдельную аналитическую проверку.

+ )} +
+
@@ -9504,12 +13224,12 @@ export function App() {
- phase + фаза {selectedStrategyNarrative?.summary ?? strategySynthesis.phaseDecision.summary}
{(selectedStrategyNarrative?.doNow ?? strategySynthesis.phaseDecision.allowedNow).slice(0, 4).map((action) => (
- now + сейчас {action}
))} @@ -9519,24 +13239,24 @@ export function App() {
Не сейчас - Что нельзя выдавать как immediate route до зрелости сайта. + Что нельзя выдавать как текущий маршрут до зрелости сайта.
{(selectedStrategyNarrative?.prepareNext ?? strategySynthesis.phaseDecision.prepareNext).slice(0, 3).map((action) => (
- prepare + готовить {action}
))} {(selectedStrategyNarrative?.hold ?? strategySynthesis.phaseDecision.hold).slice(0, 5).map((action) => (
- hold + стоп {action}
))} {(selectedStrategyNarrative?.nextEvidenceTasks ?? []).slice(0, 3).map((action) => (
- evidence + данные {action}
))} @@ -9549,8 +13269,8 @@ export function App() {
Рекомендованный путь - Confidence {getSeoStrategyConfidenceLabel(strategySynthesis.recommendedPath.confidence)} · - impact {getSeoStrategyPriorityLabel(strategySynthesis.recommendedPath.expectedImpact)} + доверие {getSeoStrategyConfidenceLabel(strategySynthesis.recommendedPath.confidence)} · + потенциал {getSeoStrategyPriorityLabel(strategySynthesis.recommendedPath.expectedImpact)}
@@ -9563,13 +13283,13 @@ export function App() {
{strategySynthesis.recommendedPath.doNow.slice(0, 4).map((action) => (
- now + сейчас {action}
))} {strategySynthesis.recommendedPath.hold.slice(0, 3).map((action) => (
- hold + стоп {action}
))} @@ -9589,8 +13309,8 @@ export function App() { {option.title} - impact {getSeoStrategyPriorityLabel(option.expectedImpact)} · effort{" "} - {getSeoStrategyPriorityLabel(option.effort)} · confidence{" "} + потенциал {getSeoStrategyPriorityLabel(option.expectedImpact)} · усилие{" "} + {getSeoStrategyPriorityLabel(option.effort)} · доверие{" "} {getSeoStrategyConfidenceLabel(option.confidence)} {option.rationale} @@ -9605,7 +13325,7 @@ export function App() {
Что уже доказано - Краткая evidence narrative, без выдуманного спроса и домыслов. + Краткая доказательная сводка, без выдуманного спроса и домыслов.
{strategySynthesis.evidenceNarrative.slice(0, 6).map((evidence) => ( @@ -9637,7 +13357,7 @@ export function App() { ))} {strategySynthesis.blockedActions.slice(0, 4).map((action) => (
- blocked + запрет {action}
))} @@ -9650,8 +13370,8 @@ export function App() {
- Yandex evidence - Факты внешнего рынка: спрос, SERP intent, конкуренты и сезонность. + Доказательства Яндекса + Факты внешнего рынка: спрос, интент выдачи, конкуренты и сезонность.
{yandexEvidence?.phrases.slice(0, 6).map((phrase) => ( @@ -9661,17 +13381,17 @@ export function App() { {phrase.phrase} - {formatCount(phrase.wordstat.totalCount ?? 0)} спрос · difficulty{" "} + {formatCount(phrase.wordstat.totalCount ?? 0)} спрос · сложность{" "} {phrase.interpretation.difficultyScore}/100 ·{" "} {getYandexEvidenceSeasonalityLabel(phrase.interpretation.seasonality)} - {phrase.serp.topDomains.slice(0, 4).join(" · ") || "SERP domains не собраны"} + {phrase.serp.topDomains.slice(0, 4).join(" · ") || "домены SERP не собраны"}
))} {!yandexEvidence || yandexEvidence.phrases.length === 0 ? (
- empty - Yandex evidence ещё не собран + пусто + Данные Yandex ещё не собраны Запусти сбор после Wordstat/keyword-map, чтобы стратегия увидела рынок.
) : null} @@ -9693,9 +13413,9 @@ export function App() { ))} {!yandexEvidence || yandexEvidence.competitorDomains.length === 0 ? (
- empty + пусто Конкуренты ещё не сняты - Нужен WebSearch evidence run. + Нужен запуск WebSearch-данных.
) : null}
@@ -9706,7 +13426,7 @@ export function App() {
- SERP interpretation + Разбор выдачи Интент выдачи, fit с целевой страницей и роль фразы в стратегии.
@@ -9726,7 +13446,7 @@ export function App() { {phrase.phrase} {getYandexEvidenceIntentLabel(phrase.intent)} ·{" "} - {getSerpRecommendedRoleLabel(phrase.recommendedRole)} · confidence{" "} + {getSerpRecommendedRoleLabel(phrase.recommendedRole)} · доверие{" "} {getSeoStrategyConfidenceLabel(phrase.confidence)} {phrase.opportunity} @@ -9738,7 +13458,7 @@ export function App() {
- Competitor angles + Конкурентные углы Не список доменов, а гипотезы: чем занята выдача.
@@ -9759,7 +13479,7 @@ export function App() {
Возможности роста - Не “слова ради слов”, а группы, где есть спрос, страница и следующий evidence. + Не “слова ради слов”, а группы, где есть спрос, страница и следующий слой данных.
{seoStrategy.opportunities.slice(0, 8).map((opportunity) => ( @@ -9778,7 +13498,7 @@ export function App() { {opportunity.title} {opportunity.targetPath ?? "страница не выбрана"} ·{" "} - {formatCount(opportunity.demand.totalFrequency)} спрос · confidence{" "} + {formatCount(opportunity.demand.totalFrequency)} спрос · доверие{" "} {getSeoStrategyConfidenceLabel(opportunity.confidence)} ·{" "} {getSeoCompetitionTierLabel(opportunity.competitionTier)} @@ -9789,9 +13509,9 @@ export function App() { ))} {seoStrategy.opportunities.length === 0 ? (
- empty + пусто Возможности ещё не доказаны - Нужны market evidence, SERP и нормализация фраз. + Нужны рыночные доказательства, разбор выдачи и нормализация фраз.
) : null}
@@ -9799,7 +13519,7 @@ export function App() {
- Evidence gaps + Пробелы доказательств Что мешает назвать стратегию зрелой и полезной.
@@ -9814,9 +13534,9 @@ export function App() { ))} {seoStrategy.missingEvidence.length === 0 ? (
- OK - Критичных gaps нет - Можно готовить human strategy approval. + ОК + Критичных пробелов нет + Можно готовить ручное утверждение стратегии.
) : null}
@@ -9836,7 +13556,7 @@ export function App() { {seoStrategy.evidence.context.gaps.length > 0 ? seoStrategy.evidence.context.gaps.slice(0, 4).join(" · ") - : "Явных context gaps в strategy contract нет."} + : "Явных пробелов контекста в контракте стратегии нет."}
@@ -9844,7 +13564,7 @@ export function App() {
- Top demand signals + Главные сигналы спроса Фразы с подтверждённой частотностью, которые реально влияют на стратегию.
@@ -9857,8 +13577,8 @@ export function App() { ))} {seoStrategy.evidence.demand.topSignals.length === 0 ? (
- empty - Нет сильных demand signals + пусто + Нет сильных сигналов спроса Нужно расширить сбор фраз и внешние источники.
) : null} @@ -9875,10 +13595,10 @@ export function App() {
{seoStrategy.strategyOptions.map((option) => (
- {option.expectedImpact} impact + {getSeoStrategyPriorityLabel(option.expectedImpact)} потенциал {option.title} - effort {option.effort} · confidence {getSeoStrategyConfidenceLabel(option.confidence)} + усилие {getSeoStrategyPriorityLabel(option.effort)} · доверие {getSeoStrategyConfidenceLabel(option.confidence)} {option.rationale}
@@ -9904,27 +13624,29 @@ export function App() { ))} {seoStrategy.risks.length === 0 ? (
- OK + ОК Критичных рисков нет - Остаётся закрыть недостающие evidence sources. + Остаётся закрыть недостающие источники доказательств.
) : null}
-
- Следующий production шаг - {seoStrategy.nextActions.map((action) => ( -

{action}

- ))} -
+
+ Следующий продуктовый шаг + {seoStrategy.nextActions.map((action) => ( +

{action}

+ ))} +
+
+ ) : (
- Сначала нужны context review, market enrichment и keyword map: стратегия не строится только на raw - scan. + Сначала нужны проверка контекста, рыночное обогащение и карта фраз: стратегия не строится только на сыром + скане.
)}
@@ -9933,7 +13655,7 @@ export function App() {
- Подготовка к model review + Подготовка к модельной проверке Что уже можно отдать на правку модели, а что сначала требует страницы или привязки.
@@ -9945,11 +13667,11 @@ export function App() { }`} >
- Model provider + Провайдер модели {defaultModelProvider ? `${defaultModelProvider.title} · ${getModelProviderStatusLabel(defaultModelProvider.status)}` - : "Provider status не загружен"} + : "Статус провайдера не загружен"} {defaultModelProvider @@ -9964,16 +13686,16 @@ export function App() { {latestModelTaskRun.task.taskType ? getModelTaskTypeLabel(latestModelTaskRun.task.taskType) - : "model task"}{" "} + : "задача модели"}{" "} · {latestModelTaskRun.estimate.inputTokenEstimate} tokens {formatDate(latestModelTaskRun.createdAt)} ) : ( <> - codex task + задача Codex Маршрут ещё не проверяли - Task route фиксируется отдельно; результат анализа сохраняется manual evidence. + Маршрут задачи фиксируется отдельно; результат анализа сохраняется как ручное доказательство. )}
@@ -9985,7 +13707,7 @@ export function App() { type="button" > {isRunningContextModelTask ? : } - Context Review + Проверка контекста
@@ -10123,12 +13845,12 @@ export function App() { {materialization.readiness.placeholderTargetCount} · отложено/отклонено:{" "} {materialization.readiness.rejectedOrDeferredCount} - Source approved: {materialization.readiness.awaitingSourceCount} + Ждёт источника: {materialization.readiness.awaitingSourceCount} План правок: {getRewritePlanStateLabel(materialization.rewritePlanState)}

{materialization.state === "complete" - ? "Очередь посадочных закрыта для текущего плана review." + ? "Очередь посадочных закрыта для текущего плана проверки." : "Здесь фиксируются решения по плановым посадочным; исходники сайта пока не меняются."}

@@ -10136,7 +13858,7 @@ export function App() { {materialization && materialization.targets.length > 0 ? (
- Почему model review пока рано + Почему модельная проверка пока рано
{materialization.targets.slice(0, 8).map((target) => { const targetPath = target.normalizedTargetPath ?? target.targetPath ?? ""; @@ -10151,7 +13873,7 @@ export function App() { {target.pageTitle ?? target.urlPath ?? target.sourcePath ?? "страница не связана"} - Сейчас это аналитический blocker, а не задача на создание или правку файла сайта. + Сейчас это аналитический блокер, а не задача на создание или правку файла сайта.
@@ -10182,7 +13904,7 @@ export function App() {

- Контракт готовит текущие значения полей для review, но новый текст появится только после + Контракт готовит текущие значения полей для проверки, но новый текст появится только после выбора модели и правил промпта.

@@ -10193,7 +13915,7 @@ export function App() {
Поля перед правкой - В review идут только поля, где найден текущий текст. + В проверку идут только поля, где найден текущий текст.
{rewriteDiff.targets @@ -10221,7 +13943,7 @@ export function App() {
Блокеры правок - Что нужно закрыть до model review. + Что нужно закрыть до модельной проверки.
{rewriteDiff.targets @@ -10239,7 +13961,7 @@ export function App() {
готово Блокеры не найдены - Следующий шаг — обсуждение model provider и review policy. + Следующий шаг — обсуждение провайдера модели и правил проверки.
) : null}
@@ -10259,7 +13981,7 @@ export function App() { ) : (
- Сначала нужен зафиксированный SEO-план: model review не создаёт правки без привязки страницы и секции. + Сначала нужен зафиксированный SEO-план: модельная проверка не создаёт правки без привязки страницы и секции.
)}
@@ -10269,7 +13991,7 @@ export function App() {
Проверка качества - Quality gate стратегии: phase runway, evidence refs, hallucination guardrail и business drift. Сайт не меняем. + Проверка стратегии: фазовый маршрут, ссылки на доказательства, защита от домыслов и бизнесовая рамка. Сайт не меняем.
@@ -10290,18 +14012,18 @@ export function App() { {strategyQualityReview?.state === "ready" ? strategyQualityReview.verdict.title - : "Сначала нужна стратегия с phaseDecision и decision options"} + : "Сначала нужна стратегия с фазовым решением и вариантами выбора"} {strategyQualityReview?.state === "ready" - ? `score ${strategyQualityReview.readiness.score} · checks ${strategyQualityReview.readiness.passedCount}/${strategyQualityReview.readiness.checkCount} · blockers ${strategyQualityReview.readiness.blockerCount}` - : "Этот gate проверяет аналитику, а не готовит source/apply route."} + ? `оценка ${strategyQualityReview.readiness.score} · проверки ${strategyQualityReview.readiness.passedCount}/${strategyQualityReview.readiness.checkCount} · блокеры ${strategyQualityReview.readiness.blockerCount}` + : "Эта проверка оценивает аналитику, а не готовит маршрут правок или применения."}

{strategyQualityReview?.state === "ready" ? strategyQualityReview.verdict.summary - : "Здесь фиксируем, можно ли доверять выбранной SEO-стратегии: нет ли преждевременных head terms, выдуманного спроса, расширения бизнеса без решения пользователя и мутных выводов."} + : "Здесь фиксируем, можно ли доверять выбранной SEO-стратегии: нет ли преждевременных широких запросов, выдуманного спроса, расширения бизнеса без решения пользователя и мутных выводов."}

@@ -10314,10 +14036,10 @@ export function App() { >
seo-strategy-quality-review.v1 - Read-only validation layer + Слой проверки только для чтения

- Проверяем зрелость стратегии перед любыми будущими rewrite/apply действиями: фазовость, evidence, - hallucination guardrail, бизнесовую рамку и понятность следующего шага. + Проверяем зрелость стратегии перед любыми будущими правками: фазовость, доказательства, + защиту от домыслов, бизнесовую рамку и понятность следующего шага.

@@ -10349,26 +14071,26 @@ export function App() { ? getStrategyQualityMetrics(strategyQualityReview) : [ { - detail: "quality gate score", - label: "Score", + detail: "оценка проверки", + label: "Оценка", tone: "warning" as const, value: 0 }, { - detail: "checks passed", - label: "Pass", + detail: "проверок пройдено", + label: "Пройдено", tone: "warning" as const, value: 0 }, { - detail: "must fix", - label: "Blockers", + detail: "нужно исправить", + label: "Блокеры", tone: "warning" as const, value: 0 }, { - detail: "needs attention", - label: "Warnings", + detail: "нужно внимание", + label: "Предупреждения", tone: "warning" as const, value: 0 } @@ -10387,34 +14109,34 @@ export function App() {
@@ -10445,19 +14167,19 @@ export function App() {
- Блокеры и warnings + Блокеры и предупреждения Короткий список того, что нельзя пропустить перед следующим аналитическим шагом.
{strategyQualityReview.blockers.slice(0, 6).map((blocker) => (
- blocker + блокер {blocker}
))} {strategyQualityReview.warnings.slice(0, 6).map((warning) => (
- warning + предупреждение {warning}
))} @@ -10475,13 +14197,13 @@ export function App() {
- Approved signals + Подтверждённые сигналы Каким частям стратегии сейчас можно доверять.
{strategyQualityReview.approvedSignals.slice(0, 8).map((signal) => (
- signal + сигнал {signal}
))} @@ -10496,7 +14218,7 @@ export function App() {
{strategyQualityReview.nextActions.map((action) => (
- next + дальше {action}
))} @@ -10507,8 +14229,8 @@ export function App() { ) : (
- Сначала собери `seo-strategy-synthesis.v1`: quality review проверяет принятую стратегию, phase runway и - evidence refs, а не raw keyword table. + Сначала собери синтез стратегии: проверка качества оценивает принятую стратегию, фазовый маршрут и + ссылки на доказательства, а не сырую таблицу фраз.
)}
@@ -10604,18 +14326,18 @@ export function App() {
Что дальше будет в рабочей зоне страницы?

- Выбранные страницы пойдут в следующий экран: там появится locked original - snapshot, рабочий draft, meta-поля, секции, видимый текст, media-контекст, - будущие seeds/Wordstat/keyword map и безопасный diff перед Apply. + Выбранные страницы пойдут в следующий экран: там появится исходный снимок только для чтения, + рабочий черновик, meta-поля, секции, видимый текст, media-контекст, + будущие фразы/Wordstat/карта фраз и безопасное сравнение изменений перед применением.

Рабочая зона выбранного scope - Original read-only, working draft сохраняется в Postgres + MinIO. + Исходник только для чтения, рабочий черновик сохраняется в Postgres + MinIO.
- Apply пока не трогаем + Применение пока не трогаем
{selectedWorkspaceScopeItems.map((scopeItem) => { diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css index f9e0b16..852144a 100644 --- a/seo_mode/seo_mode/app/src/styles.css +++ b/seo_mode/seo_mode/app/src/styles.css @@ -30,11 +30,22 @@ --nodedc-glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34); --nodedc-glass-blur: blur(24px) saturate(128%) brightness(1.05); --nodedc-glass-panel-blur: blur(64px) saturate(150%) brightness(1.08); - --nodedc-header-dropdown-bg: var(--nodedc-glass-panel-surface-strong); - --nodedc-header-dropdown-item-bg: var(--nodedc-glass-control-bg); - --nodedc-header-dropdown-item-hover-bg: var(--nodedc-glass-control-hover); - --nodedc-header-dropdown-item-active-bg: rgba(255, 255, 255, 0.24); - --nodedc-header-dropdown-item-active-color: var(--ink); + --nodedc-header-dropdown-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.18)), + rgba(255, 255, 255, 0.24); + --nodedc-header-dropdown-item-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.16)), + rgba(255, 255, 255, 0.28); + --nodedc-header-dropdown-item-hover-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.24)), + rgba(255, 255, 255, 0.38); + --nodedc-header-dropdown-item-active-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.64), rgba(255, 255, 255, 0.28)), + rgba(255, 255, 255, 0.46); + --nodedc-header-dropdown-item-active-color: rgba(10, 12, 14, 0.94); + --nodedc-layer-base: 0; + --nodedc-layer-panel: 100; + --nodedc-layer-panel-raised: 200; + --nodedc-layer-header: 400; + --nodedc-layer-modal: 800; + --nodedc-layer-popover: 900; + --nodedc-layer-toast: 950; font-family: "Aptos", "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; @@ -72,7 +83,7 @@ input { .app-header { position: sticky; top: 0; - z-index: 20; + z-index: var(--nodedc-layer-header); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; @@ -244,7 +255,7 @@ input { position: absolute; top: calc(100% + 10px); right: 0; - z-index: 15; + z-index: var(--nodedc-layer-popover); display: grid; gap: 8px; width: min(460px, calc(100vw - 48px)); @@ -281,7 +292,7 @@ input { .service-modal-backdrop { position: fixed; inset: 0; - z-index: 80; + z-index: var(--nodedc-layer-modal); display: grid; place-items: center; padding: 22px; @@ -2642,6 +2653,52 @@ input { line-height: 1.38; } +.stage-6 .scan-summary { + gap: 10px; + padding: 0 !important; + background: transparent !important; + border: 0 !important; + box-shadow: none !important; +} + +.stage-6 .scan-summary::after { + content: none !important; +} + +.stage-6 .market-heading { + align-items: center; + min-height: 74px; + padding: 18px 20px !important; + border-radius: 18px !important; +} + +.stage-6 .market-heading > div { + gap: 5px; +} + +.stage-6 .market-heading strong { + font-size: 17px; + line-height: 1.18; +} + +.stage-6 .market-heading small { + max-width: 840px; + font-size: 13px; + line-height: 1.38; +} + +.stage-6 .market-heading > svg { + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 8px; + color: #1f5f43; + background: rgba(47, 107, 79, 0.08); + border: 1px solid rgba(47, 107, 79, 0.14); + border-radius: 999px; + box-sizing: content-box; +} + .market-state-card { align-items: center; padding: 10px; @@ -2957,7 +3014,8 @@ input { .market-state-card > div > span, .market-provider > span, .market-seed-list span, -.market-brief-list span { +.market-brief-list span, +.market-next-actions span { justify-self: start; padding: 4px 8px; color: #70521f; @@ -7638,6 +7696,1166 @@ input[type="checkbox"] { font-weight: 760; } +.strategy-cockpit { + display: grid; + gap: 12px; +} + +.strategy-decision-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.92fr); + gap: 12px; + padding: 14px; + background: #fff; + border: 1px solid var(--line); + border-left: 4px solid #2f6b4f; + border-radius: 8px; +} + +.strategy-decision-toolbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding-bottom: 10px; + border-bottom: 1px solid rgba(24, 32, 29, 0.08); +} + +.strategy-decision-toolbar > span { + flex: 0 0 auto; + color: #4e6976; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-decision-main, +.strategy-decision-side, +.strategy-section, +.strategy-section-heading, +.strategy-landing-card, +.strategy-plan-grid article, +.strategy-blocked-grid article { + display: grid; + min-width: 0; +} + +.strategy-decision-main { + align-content: start; + gap: 8px; +} + +.strategy-decision-side { + align-content: space-between; + gap: 10px; +} + +.strategy-decision-main > span, +.strategy-section-heading span, +.strategy-coverage-strip span, +.strategy-data-table span, +.strategy-landing-head span, +.strategy-chip-list span, +.strategy-required-list span, +.strategy-plan-grid span, +.strategy-choice-grid span { + color: #4e6976; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-decision-main strong { + color: var(--ink); + font-size: 26px; + line-height: 1.08; + overflow-wrap: anywhere; +} + +.strategy-decision-main p, +.strategy-landing-card p, +.strategy-choice-grid p { + margin: 0; + color: var(--ink); + font-size: 14px; + font-weight: 780; + line-height: 1.42; +} + +.strategy-decision-main small, +.strategy-section-heading small, +.strategy-coverage-strip small, +.strategy-data-table small, +.strategy-landing-card small, +.strategy-landing-meta span, +.strategy-plan-grid small, +.strategy-blocked-grid small, +.strategy-choice-grid small { + color: var(--muted); + font-size: 12px; + font-style: normal; + font-weight: 760; + line-height: 1.38; + overflow-wrap: anywhere; +} + +.strategy-coverage-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 8px; +} + +.strategy-coverage-strip article { + display: grid; + align-content: start; + gap: 4px; + min-width: 0; + min-height: 86px; + padding: 9px; + background: #f8faf8; + border: 1px solid rgba(47, 107, 79, 0.14); + border-radius: 8px; +} + +.strategy-coverage-strip strong, +.strategy-section-heading strong, +.strategy-data-table strong, +.strategy-landing-card strong, +.strategy-plan-grid strong, +.strategy-blocked-grid strong { + color: var(--ink); + font-size: 14px; + line-height: 1.24; + overflow-wrap: anywhere; +} + +.strategy-section { + gap: 10px; + padding: 12px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-decision-rule { + padding: 7px 9px; + color: #1f5f43 !important; + background: rgba(47, 107, 79, 0.065); + border: 1px solid rgba(47, 107, 79, 0.14); + border-radius: 7px; +} + +.strategy-lock-summary-compact { + display: grid; + gap: 10px; + padding: 10px 12px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-lock-summary-compact summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 10px; + align-items: start; + cursor: pointer; + list-style: none; +} + +.strategy-lock-summary-compact summary::-webkit-details-marker { + display: none; +} + +.strategy-lock-summary-compact summary span { + grid-row: span 2; + color: #4e6976; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-lock-summary-compact summary strong { + color: var(--ink); + font-size: 13px; + line-height: 1.25; +} + +.strategy-lock-summary-compact summary small { + color: var(--muted); + font-size: 12px; + font-weight: 760; + line-height: 1.35; +} + +.strategy-lock-summary-compact[open] .strategy-lock-grid { + margin-top: 10px; +} + +.strategy-decision-confirm-backdrop { + position: fixed; + inset: 0; + z-index: 180; + display: grid; + place-items: center; + padding: 18px; + background: rgba(10, 12, 14, 0.34); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.strategy-decision-confirm { + display: grid; + gap: 12px; + width: min(760px, 100%); + max-height: min(86vh, 760px); + overflow: auto; + padding: 14px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 12px; + box-shadow: 0 30px 120px rgba(0, 0, 0, 0.28); +} + +.strategy-decision-confirm header, +.strategy-decision-confirm footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.strategy-decision-confirm header > div { + display: grid; + gap: 4px; +} + +.strategy-decision-confirm header span, +.strategy-confirm-grid span { + color: #4e6976; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-decision-confirm header strong { + color: var(--ink); + font-size: 18px; + line-height: 1.18; +} + +.strategy-decision-confirm header button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + color: var(--ink); + background: rgba(24, 32, 29, 0.055); + border: 0; + border-radius: 999px; + cursor: pointer; +} + +.strategy-confirm-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-confirm-grid article { + display: grid; + align-content: start; + gap: 5px; + min-width: 0; + padding: 10px; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-confirm-grid strong { + color: var(--ink); + font-size: 14px; + line-height: 1.24; + overflow-wrap: anywhere; +} + +.strategy-confirm-grid small { + color: var(--muted); + font-size: 12px; + font-weight: 760; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.strategy-decision-confirm footer { + justify-content: flex-end; +} + +.strategy-stage-input-contract { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 10px; + padding: 12px; + background: rgba(47, 107, 79, 0.055); + border: 1px solid rgba(47, 107, 79, 0.16); + border-radius: 8px; +} + +.strategy-stage-input-contract > div { + display: grid; + align-content: start; + gap: 5px; + min-width: 0; +} + +.strategy-stage-input-contract span { + color: #2f6b4f; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-stage-input-contract strong { + color: var(--ink); + font-size: 14px; + line-height: 1.24; + overflow-wrap: anywhere; +} + +.strategy-stage-input-contract small { + color: var(--muted); + font-size: 12px; + font-weight: 760; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.strategy-section-heading { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; +} + +.strategy-section-heading > div { + display: grid; + gap: 3px; +} + +.strategy-table-wrap { + overflow-x: auto; + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 8px; +} + +.strategy-data-table { + width: 100%; + min-width: 860px; + border-collapse: collapse; + background: #fff; +} + +.strategy-data-table th, +.strategy-data-table td { + padding: 10px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(24, 32, 29, 0.07); +} + +.strategy-data-table th { + color: #4e6976; + background: #f8faf8; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-data-table td { + color: var(--ink); + font-size: 12px; + font-weight: 760; + line-height: 1.38; +} + +.strategy-data-table td > small, +.strategy-data-table td > strong { + display: block; +} + +.strategy-data-table tr:last-child td { + border-bottom: 0; +} + +.strategy-keyword-group-row td { + padding-top: 12px; + padding-bottom: 8px; + background: rgba(83, 104, 121, 0.055); +} + +.strategy-keyword-group-row strong, +.strategy-keyword-group-row small { + display: inline-flex; + margin-right: 8px; +} + +.strategy-row-details { + margin-top: 4px; +} + +.strategy-row-details summary { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0; + color: #4e6976; + background: transparent; + border-radius: 0; + font-size: 11px; + font-weight: 900; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.strategy-row-details small { + margin-top: 5px; +} + +.strategy-keyword-more-row td { + background: rgba(83, 104, 121, 0.035); +} + +.strategy-keyword-more-row td { + display: flex; + align-items: center; + gap: 8px; +} + +.strategy-keyword-more-row small { + color: var(--muted); + font-size: 12px; + font-weight: 760; +} + +.strategy-data-table .collected, +.strategy-data-table .ready_for_collection, +.strategy-data-table .partial_ready, +.strategy-data-table .not_configured, +.strategy-landing-head .collected, +.strategy-landing-head .ready_for_collection, +.strategy-landing-head .partial_ready, +.strategy-landing-head .not_configured { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-data-table .collected, +.strategy-landing-head .collected { + color: #2f6b4f; + background: rgba(47, 107, 79, 0.08); +} + +.strategy-data-table .ready_for_collection, +.strategy-landing-head .ready_for_collection, +.strategy-data-table .partial_ready, +.strategy-landing-head .partial_ready { + color: #8a5a14; + background: rgba(178, 119, 31, 0.08); +} + +.strategy-data-table .not_configured, +.strategy-landing-head .not_configured { + color: #8b2f2f; + background: rgba(139, 47, 47, 0.07); +} + +.strategy-landing-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-scenario-decision-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-scenario-decision-grid article { + display: grid; + align-content: start; + gap: 9px; + min-width: 0; + padding: 10px; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-scenario-decision-grid article.selected { + background: rgba(47, 107, 79, 0.075); + border-color: rgba(47, 107, 79, 0.28); + box-shadow: inset 0 0 0 1px rgba(47, 107, 79, 0.14); +} + +.strategy-scenario-card-head, +.strategy-scenario-card-metrics, +.strategy-scenario-card-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.strategy-scenario-card-head { + align-items: flex-start; + flex-direction: column; +} + +.strategy-scenario-card-head span, +.strategy-scenario-card-metrics span, +.strategy-scenario-card-tags span, +.strategy-landing-lanes > section > span, +.strategy-control-grid span, +.strategy-ledger-grid span, +.strategy-lock-grid span, +.strategy-scorecard-grid span, +.strategy-positioning-grid span, +.strategy-serp-grid span, +.strategy-serp-summary-grid span, +.strategy-serp-type-grid span, +.strategy-cluster-phrase-split span, +.strategy-diff-grid span, +.strategy-content-gap-list span, +.strategy-forecast-formula span { + color: #4e6976; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-scenario-card-head strong, +.strategy-ledger-grid strong, +.strategy-lock-grid strong, +.strategy-scorecard-grid strong, +.strategy-positioning-grid strong, +.strategy-serp-grid strong, +.strategy-serp-summary-grid strong, +.strategy-serp-type-grid strong, +.strategy-cluster-phrase-split strong, +.strategy-diff-grid strong, +.strategy-content-gap-list strong, +.strategy-forecast-formula strong { + color: var(--ink); + font-size: 14px; + line-height: 1.22; + overflow-wrap: anywhere; +} + +.strategy-scenario-decision-grid p { + margin: 0; + color: var(--ink); + font-size: 12px; + font-weight: 760; + line-height: 1.38; +} + +.strategy-scenario-decision-grid small, +.strategy-ledger-grid small, +.strategy-lock-grid small, +.strategy-scorecard-grid small, +.strategy-positioning-grid small, +.strategy-serp-grid small, +.strategy-serp-summary-grid small, +.strategy-serp-type-grid small, +.strategy-cluster-phrase-split small, +.strategy-diff-grid small, +.strategy-content-gap-list small, +.strategy-forecast-formula small, +.strategy-scenario-comparison small { + color: var(--muted); + font-size: 12px; + font-weight: 760; + line-height: 1.35; +} + +.strategy-scenario-card-metrics span, +.strategy-scenario-card-tags span { + padding: 5px 7px; + color: var(--ink); + background: rgba(24, 32, 29, 0.04); + border: 1px solid rgba(24, 32, 29, 0.06); + border-radius: 7px; + text-transform: none; +} + +.strategy-forecast-layout { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); + gap: 8px; +} + +.strategy-ledger-grid, +.strategy-lock-grid, +.strategy-scorecard-grid, +.strategy-positioning-grid, +.strategy-serp-grid, +.strategy-serp-summary-grid, +.strategy-serp-type-grid, +.strategy-cluster-phrase-split, +.strategy-diff-grid { + display: grid; + gap: 8px; +} + +.strategy-ledger-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.strategy-lock-grid { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.strategy-scorecard-grid { + grid-column: 1 / -1; + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.strategy-positioning-grid, +.strategy-serp-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.strategy-serp-summary-grid, +.strategy-serp-type-grid, +.strategy-diff-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.strategy-cluster-phrase-split { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.strategy-ledger-grid article, +.strategy-lock-grid article, +.strategy-scorecard-grid article, +.strategy-positioning-grid article, +.strategy-serp-grid article, +.strategy-serp-summary-grid article, +.strategy-serp-type-grid article, +.strategy-cluster-phrase-split article, +.strategy-diff-grid article, +.strategy-content-gap-list article, +.strategy-forecast-formula { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; + padding: 10px; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-ledger-grid article.tone-good, +.strategy-lock-grid article.tone-good, +.strategy-scorecard-grid article.tone-good, +.strategy-serp-summary-grid article.tone-good, +.strategy-serp-type-grid article.tone-good, +.strategy-cluster-phrase-split article.tone-good, +.strategy-diff-grid article.tone-good { + background: rgba(47, 107, 79, 0.055); + border-color: rgba(47, 107, 79, 0.18); +} + +.strategy-ledger-grid article.tone-warning, +.strategy-lock-grid article.tone-warning, +.strategy-scorecard-grid article.tone-warning, +.strategy-serp-summary-grid article.tone-warning, +.strategy-serp-type-grid article.tone-warning, +.strategy-cluster-phrase-split article.tone-warning, +.strategy-diff-grid article.tone-warning { + background: rgba(178, 119, 31, 0.055); + border-color: rgba(178, 119, 31, 0.18); +} + +.strategy-ledger-grid article.tone-problem, +.strategy-lock-grid article.tone-problem, +.strategy-scorecard-grid article.tone-problem, +.strategy-serp-summary-grid article.tone-problem, +.strategy-serp-type-grid article.tone-problem, +.strategy-cluster-phrase-split article.tone-problem, +.strategy-diff-grid article.tone-problem { + background: rgba(139, 47, 47, 0.045); + border-color: rgba(139, 47, 47, 0.14); +} + +.strategy-forecast-formula { + grid-column: 1 / -1; +} + +.strategy-forecast-formula strong { + padding: 7px 9px; + background: rgba(47, 107, 79, 0.055); + border: 1px solid rgba(47, 107, 79, 0.12); + border-radius: 7px; + font-size: 13px; +} + +.strategy-scenario-comparison { + display: grid; + gap: 8px; + margin-top: 8px; + padding: 10px; + background: rgba(83, 104, 121, 0.05); + border: 1px solid rgba(83, 104, 121, 0.12); + border-radius: 8px; +} + +.strategy-scenario-comparison > div:first-child { + display: grid; + gap: 3px; +} + +.strategy-scenario-comparison .selected-scenario-cell { + color: #1f5f43; + background: rgba(47, 107, 79, 0.075); + font-weight: 900; +} + +.strategy-scenario-diff { + display: grid; + gap: 8px; +} + +.strategy-scenario-diff > strong, +.strategy-content-gap-list > strong { + color: var(--ink); + font-size: 13px; + font-weight: 900; +} + +.strategy-serp-summary-grid, +.strategy-serp-type-grid, +.strategy-cluster-phrase-split { + margin-top: 8px; +} + +.strategy-content-gap-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; +} + +.strategy-content-gap-list > strong { + grid-column: 1 / -1; +} + +.strategy-control-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-control-grid > div { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 7px; + min-width: 0; + padding: 10px; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-control-grid span { + flex: 0 0 100%; +} + +.strategy-control-grid button { + min-height: 30px; + padding: 5px 9px; + color: var(--ink); + background: #fff; + border: 1px solid var(--line); + border-radius: 7px; + font-size: 12px; + font-weight: 820; + cursor: pointer; +} + +.strategy-control-grid button.active { + color: #fff; + background: #2f6b4f; + border-color: #2f6b4f; +} + +.strategy-landing-lanes { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-landing-lanes > section { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; +} + +.strategy-landing-card { + align-content: start; + gap: 9px; + padding: 10px; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-landing-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px 8px; + align-items: start; +} + +.strategy-landing-head span { + grid-row: span 2; +} + +.strategy-landing-meta, +.strategy-chip-list, +.strategy-required-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.strategy-landing-meta span { + padding: 5px 7px; + background: rgba(24, 32, 29, 0.04); + border: 1px solid rgba(24, 32, 29, 0.06); + border-radius: 7px; +} + +.strategy-chip-list span, +.strategy-required-list span { + padding: 5px 7px; + background: rgba(83, 104, 121, 0.07); + border-radius: 7px; + text-transform: none; +} + +.strategy-required-list span { + color: var(--ink); + background: rgba(47, 107, 79, 0.06); + font-weight: 780; +} + +.strategy-landing-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.strategy-landing-actions .tiny-action { + min-height: 28px; + padding: 4px 8px; + font-size: 11px; +} + +.strategy-plan-grid, +.strategy-blocked-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-plan-grid article, +.strategy-blocked-grid article { + align-content: start; + gap: 6px; + min-height: 112px; + padding: 10px; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-blocked-grid article { + background: rgba(139, 47, 47, 0.045); + border-color: rgba(139, 47, 47, 0.14); +} + +.strategy-blocked-grid strong { + color: #7a2a2a; +} + +.strategy-dev-details-body { + display: grid; + gap: 12px; +} + +.strategy-hero { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); + gap: 12px; + padding: 14px; + background: #fff; + border: 1px solid var(--line); + border-left: 4px solid #536879; + border-radius: 8px; +} + +.strategy-hero.tone-good { + border-left-color: #2f6b4f; +} + +.strategy-hero.tone-warning { + border-left-color: #b2771f; +} + +.strategy-hero.tone-problem { + border-left-color: #8b2f2f; +} + +.strategy-hero-main, +.strategy-hero-side, +.strategy-hero-side > div:first-child, +.strategy-action-grid section, +.strategy-route-board, +.strategy-choice-panel { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; +} + +.strategy-hero-main > span, +.strategy-hero-side span, +.strategy-action-grid span, +.strategy-evidence-grid span, +.strategy-route-heading span, +.strategy-route-grid span, +.strategy-choice-grid span, +.strategy-gate-grid span { + color: #4e6976; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.strategy-hero-main strong { + color: var(--ink); + font-size: 28px; + line-height: 1.05; +} + +.strategy-hero-side strong, +.strategy-route-heading strong { + color: var(--ink); + font-size: 16px; + line-height: 1.22; +} + +.strategy-hero-main p, +.strategy-choice-grid p { + margin: 0; + color: var(--ink); + font-size: 14px; + font-weight: 780; + line-height: 1.42; + overflow-wrap: anywhere; +} + +.strategy-hero-main small, +.strategy-hero-side small, +.strategy-action-grid strong, +.strategy-evidence-grid small, +.strategy-route-grid small, +.strategy-route-grid em, +.strategy-choice-grid small { + color: var(--muted); + font-size: 12px; + font-style: normal; + font-weight: 760; + line-height: 1.38; + overflow-wrap: anywhere; +} + +.strategy-hero-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px; +} + +.strategy-gate-grid, +.strategy-action-grid, +.strategy-evidence-grid, +.strategy-route-grid, +.strategy-choice-grid { + display: grid; + gap: 8px; +} + +.strategy-gate-grid, +.strategy-action-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.strategy-evidence-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.strategy-gate-grid article, +.strategy-action-grid section, +.strategy-evidence-grid article, +.strategy-route-board, +.strategy-choice-panel, +.strategy-choice-grid article { + min-width: 0; + padding: 10px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.strategy-gate-grid article { + display: grid; + gap: 5px; + min-height: 112px; +} + +.strategy-gate-grid article.ready { + background: rgba(47, 107, 79, 0.065); + border-color: rgba(47, 107, 79, 0.2); +} + +.strategy-gate-grid article.locked { + background: rgba(139, 47, 47, 0.045); + border-color: rgba(139, 47, 47, 0.14); +} + +.strategy-gate-grid article.ready span, +.strategy-evidence-grid article.tone-good span { + color: #2f6b4f; +} + +.strategy-gate-grid article.locked span, +.strategy-evidence-grid article.tone-problem span { + color: #8b2f2f; +} + +.strategy-gate-grid strong, +.strategy-evidence-grid strong, +.strategy-choice-grid strong { + color: var(--ink); + font-size: 14px; + line-height: 1.22; +} + +.strategy-gate-grid small { + color: var(--muted); + font-size: 12px; + font-weight: 760; + line-height: 1.35; +} + +.strategy-action-grid section > div { + display: grid; + gap: 6px; +} + +.strategy-action-grid strong { + padding: 7px 8px; + background: rgba(24, 32, 29, 0.035); + border: 1px solid rgba(24, 32, 29, 0.06); + border-radius: 7px; +} + +.strategy-evidence-grid article { + display: grid; + gap: 5px; + min-height: 104px; +} + +.strategy-evidence-grid article.tone-good { + background: rgba(47, 107, 79, 0.055); + border-color: rgba(47, 107, 79, 0.18); +} + +.strategy-evidence-grid article.tone-warning { + background: rgba(178, 119, 31, 0.06); + border-color: rgba(178, 119, 31, 0.18); +} + +.strategy-evidence-grid article.tone-warning span { + color: #8a5a14; +} + +.strategy-route-board, +.strategy-choice-panel { + gap: 10px; +} + +.strategy-route-heading { + display: grid; + gap: 4px; +} + +.strategy-route-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.strategy-route-grid button { + display: grid; + align-content: start; + gap: 7px; + min-height: 168px; + padding: 10px; + text-align: left; + background: #fafafa; + border: 1px solid var(--line); + border-radius: 8px; + cursor: pointer; +} + +.strategy-route-grid button.current { + background: rgba(47, 107, 79, 0.075); + border-color: rgba(47, 107, 79, 0.28); + box-shadow: inset 0 0 0 1px rgba(47, 107, 79, 0.2); +} + +.strategy-route-grid button.locked { + background: rgba(24, 32, 29, 0.026); +} + +.strategy-route-grid strong { + color: var(--ink); + font-size: 14px; + line-height: 1.2; +} + +.strategy-choice-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.strategy-choice-grid article { + display: grid; + gap: 7px; +} + .strategy-phase-selector { display: grid; gap: 10px; @@ -7766,10 +8984,67 @@ input[type="checkbox"] { justify-content: flex-start; } + .strategy-decision-hero, + .strategy-coverage-strip, + .strategy-ledger-grid, + .strategy-lock-grid, + .strategy-scenario-decision-grid, + .strategy-forecast-layout, + .strategy-scorecard-grid, + .strategy-positioning-grid, + .strategy-serp-grid, + .strategy-serp-summary-grid, + .strategy-serp-type-grid, + .strategy-cluster-phrase-split, + .strategy-diff-grid, + .strategy-confirm-grid, + .strategy-stage-input-contract, + .strategy-content-gap-list, + .strategy-control-grid, + .strategy-landing-lanes, + .strategy-landing-grid, + .strategy-plan-grid, + .strategy-blocked-grid, + .strategy-hero, + .strategy-gate-grid, + .strategy-action-grid, + .strategy-evidence-grid, + .strategy-route-grid, + .strategy-choice-grid, .strategy-phase-tabs, .strategy-phase-detail-grid { grid-template-columns: 1fr; } + + .strategy-decision-main strong { + font-size: 22px; + } + + .strategy-decision-toolbar, + .stage-6 .market-heading { + align-items: stretch; + flex-direction: column; + } + + .strategy-hero-actions { + justify-content: flex-start; + } + + .strategy-section-heading { + grid-template-columns: 1fr; + } + + .strategy-lock-summary-compact summary, + .strategy-decision-confirm header, + .strategy-decision-confirm footer { + grid-template-columns: 1fr; + align-items: stretch; + flex-direction: column; + } + + .strategy-hero-main strong { + font-size: 22px; + } } .topbar { @@ -10403,7 +11678,7 @@ textarea:focus { .seo-launcher-shell .app-header { position: sticky !important; top: 0; - z-index: 80; + z-index: var(--nodedc-layer-header); display: block !important; width: min(100%, calc(100vw - var(--nodedc-shell-frame-gutter-x))) !important; margin-inline: auto !important; @@ -10584,7 +11859,7 @@ textarea:focus { .seo-project-menu-popover { position: absolute; - z-index: 120; + z-index: var(--nodedc-layer-popover); top: calc(100% + 0.7rem); left: 50%; display: grid; @@ -11139,7 +12414,7 @@ textarea:focus { .seo-project-name-modal-backdrop { position: fixed; inset: 0; - z-index: 140; + z-index: var(--nodedc-layer-modal); display: grid; place-items: center; padding: 1rem; @@ -11437,7 +12712,7 @@ body:has(.seo-launcher-shell) { .seo-launcher-shell.project-open .topbar { position: relative; - z-index: 2; + z-index: var(--nodedc-layer-panel-raised); display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-row: auto; @@ -11501,6 +12776,131 @@ body:has(.seo-launcher-shell) { color: #fff; } +.seo-work-panel-export { + position: relative; + display: grid; +} + +.seo-work-panel-export-menu { + position: fixed; + isolation: isolate; + z-index: var(--nodedc-layer-popover); + display: grid; + min-width: 13.75rem; + gap: 0.36rem; + padding: 0.56rem; + border: 0; + border-radius: 1.28rem; + background: var(--nodedc-header-dropdown-bg); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.76), + 0 24px 70px rgba(24, 28, 26, 0.16); + backdrop-filter: var(--nodedc-glass-panel-blur); + -webkit-backdrop-filter: var(--nodedc-glass-panel-blur); +} + +.seo-work-panel-export-menu::after { + pointer-events: none; + content: none; +} + +.seo-work-panel-export-menu button, +.seo-work-panel-export-menu button:last-child, +.seo-work-panel-actions .seo-work-panel-export-menu button, +.seo-work-panel-actions .seo-work-panel-export-menu button:last-child { + position: relative; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + width: 100%; + height: auto; + min-height: 2.55rem; + padding: 0 0.7rem; + border: 0; + border-radius: 0.95rem; + background: var(--nodedc-header-dropdown-item-bg); + box-shadow: var(--nodedc-glass-control-shadow); + color: rgba(10, 12, 14, 0.88); + font-size: 0.84rem; + font-weight: 800; + text-align: left; + line-height: 1; + backdrop-filter: var(--nodedc-glass-blur); + -webkit-backdrop-filter: var(--nodedc-glass-blur); +} + +.seo-work-panel-export-menu button:hover, +.seo-work-panel-actions .seo-work-panel-export-menu button:hover { + background: var(--nodedc-header-dropdown-item-hover-bg); + color: rgba(10, 12, 14, 0.96); + transform: none; +} + +.seo-work-panel-export-menu button svg { + flex: 0 0 auto; +} + +.seo-work-panel-export-menu button:disabled, +.seo-work-panel-actions button:disabled, +.seo-work-panel-actions .seo-work-panel-export-menu button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.seo-strategy-pdf-render-root { + position: absolute; + top: 0; + left: -200vw; + z-index: -1; + display: grid; + gap: 12px; + padding: 18px; + background: #f7f7f7; + color: var(--ink); + pointer-events: none; +} + +.seo-strategy-pdf-title { + display: flex; + align-items: center; + gap: 14px; + min-height: 76px; + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} + +.seo-strategy-pdf-title img, +.seo-strategy-pdf-title svg { + display: block; + width: 88px; + height: auto; +} + +.seo-strategy-pdf-title svg { + color: var(--ink); +} + +.seo-strategy-pdf-title div { + display: grid; + gap: 4px; +} + +.seo-strategy-pdf-title strong { + color: var(--ink); + font-size: 24px; + line-height: 1.08; +} + +.seo-strategy-pdf-title span { + color: var(--muted); + font-size: 13px; + font-weight: 760; +} + .seo-launcher-shell.project-open .project-setup { grid-row: auto; grid-column: auto; @@ -11860,18 +13260,18 @@ body:has(.seo-launcher-shell) { } .seo-project-menu-popover { - left: calc(100% - 0.45rem) !important; + left: var(--nodedc-shell-pill-padding) !important; right: auto !important; top: calc(100% + 0.65rem) !important; transform: none !important; width: min(27rem, calc(100vw - 3rem)) !important; - border: 1px solid rgba(255, 255, 255, 0.42) !important; + border: 0 !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.48), rgba(255, 255, 255, 0.24)), - rgba(243, 244, 241, 0.34) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.18)), + rgba(243, 244, 241, 0.24) !important; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.72), - 0 30px 90px rgba(24, 28, 26, 0.24) !important; + inset 0 1px 0 rgba(255, 255, 255, 0.76), + 0 30px 90px rgba(24, 28, 26, 0.16) !important; backdrop-filter: blur(30px) saturate(1.16) !important; -webkit-backdrop-filter: blur(30px) saturate(1.16) !important; } diff --git a/seo_mode/seo_mode/package-lock.json b/seo_mode/seo_mode/package-lock.json index 2404ed1..c49cd47 100644 --- a/seo_mode/seo_mode/package-lock.json +++ b/seo_mode/seo_mode/package-lock.json @@ -41,6 +41,8 @@ "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "lucide-react": "^0.561.0", "motion": "^12.27.0", "react": "^19.2.3", @@ -720,6 +722,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1991,6 +2002,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -2010,6 +2027,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -2030,7 +2054,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2057,6 +2081,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -2624,6 +2655,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -2773,6 +2813,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2937,6 +2997,18 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2954,6 +3026,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3256,6 +3337,16 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3457,6 +3548,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3475,6 +3593,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3668,6 +3792,19 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3719,6 +3856,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3776,6 +3919,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4284,6 +4444,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -4512,6 +4679,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4588,6 +4765,13 @@ } } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4598,6 +4782,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -4848,6 +5042,16 @@ "node": ">= 10.x" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4901,6 +5105,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -4911,6 +5125,15 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5087,6 +5310,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",