From 2a0e72543e3e35eb97aab0619b8b3f07414aac23 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 5 Jul 2026 11:07:30 +0300 Subject: [PATCH] feat(seo): refine keyword curation workspace --- seo_mode/seo_mode/app/src/App.tsx | 2834 +++++++++++++++--- seo_mode/seo_mode/app/src/NdcGlassSelect.tsx | 246 ++ seo_mode/seo_mode/app/src/api.ts | 349 ++- seo_mode/seo_mode/app/src/main.tsx | 44 +- seo_mode/seo_mode/app/src/styles.css | 816 ++++- 5 files changed, 3870 insertions(+), 419 deletions(-) create mode 100644 seo_mode/seo_mode/app/src/NdcGlassSelect.tsx diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index 94e9863..e2a7563 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -8,6 +8,7 @@ import { Database, Download, ExternalLink, + EyeOff, FileText, FolderCheck, FolderOpen, @@ -17,6 +18,7 @@ import { Loader2, Maximize2, Minimize2, + Pencil, Plug, Plus, RefreshCw, @@ -33,6 +35,7 @@ import { import type { CSSProperties, ChangeEvent, DragEvent, FormEvent, PointerEvent as ReactPointerEvent } from "react"; import { Fragment, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { NdcGlassSelect, type NdcGlassSelectOption } from "./NdcGlassSelect"; 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"; @@ -59,9 +62,12 @@ import { approveKeywordMapDecisions, copyProject, createSeoAnalyticsModelSetupCommand, + deleteKeywordContextSnapshot, deleteProject, downloadSemanticAnalysisExport, fetchExternalServiceSettings, + fetchKeywordContextSnapshots, + fetchLatestKeywordAnalysisWorkflow, fetchModelProviderStatus, fetchHealth, fetchLatestKeywordCleaning, @@ -88,6 +94,7 @@ import { probeSeoAnalyticsModel, probeYandexAiStudioCredential, renameProject, + renameKeywordContextSnapshot, resetPageWorkspace, runMarketEnrichment, runProjectScan, @@ -103,8 +110,11 @@ import { saveManualKeywordCleaning, savePageSemanticBlocks, saveContentFieldDraft, + saveKeywordContextSnapshot, savePageWorkspace, + startKeywordAnalysisWorkflow, updateExternalServiceSettings, + updateKeywordContextSnapshotCuration, updateProjectOntologyApproval, updateProjectOntologyConfirmations, updateProjectPageSelection, @@ -112,10 +122,13 @@ import { type AnchorReviewDecisionInput, type BrowserFolderFileInput, type ContentFieldDraft, + type DemandCollectionProfile, type ExternalServiceSettings, type ExternalServiceUpdateInput, type HealthResponse, + type KeywordAnalysisWorkflowContract, type KeywordCleaningContract, + type KeywordContextSnapshotSummary, type KeywordMapContract, type KeywordMapRole, type KeywordMapRoleOverrideInput, @@ -147,6 +160,7 @@ import { type SemanticAnalysisRun, type SemanticExportFormat, type StrategyQualityReviewContract, + type StrategyPhaseAction, type StrategySynthesisContract, type YandexEvidenceContract } from "./api"; @@ -205,6 +219,28 @@ const keywordCurationColumns: Array<{ } ]; +const demandCollectionProfileOptions: Array> = [ + { + value: "contextual", + label: "Контекстный охват" + }, + { + value: "market_wide", + label: "Максимальный рыночный охват" + } +]; + +const demandCollectionProfileCopy: Record = { + contextual: { + description: "Точный сбор вокруг утвержденных смыслов и ближайших формулировок.", + label: "Контекстный охват" + }, + market_wide: { + description: "Расширенный сбор по рынку, коммерческим и смежным формулировкам.", + label: "Максимальный рыночный охват" + } +}; + const stageMeta = [ { title: "Источник проекта", @@ -229,7 +265,7 @@ const stageMeta = [ { title: "Ключи и спрос", sectionTitle: "Анализ ключей", - description: "Выбираем смысловые якоря, запускаем Wordstat только по ручной очереди и готовим карту ключей." + description: "Выбираем смысловые якоря, собираем рыночную базу Wordstat и готовим карту ключей." }, { title: "Стратегия и карта посадочных", @@ -489,6 +525,36 @@ function formatStrategyDisplayText(value: string) { .replace(/для правки и применение/g, "для правки и применения"); } +function isStrategyPhaseAction(value: string | StrategyPhaseAction): value is StrategyPhaseAction { + return typeof value === "object" && value !== null && typeof value.title === "string"; +} + +function getStrategyPhaseActionTitle(value: string | StrategyPhaseAction) { + return formatStrategyDisplayText(isStrategyPhaseAction(value) ? value.title : value); +} + +function getStrategyPhaseActionDetail(value: string | StrategyPhaseAction) { + if (!isStrategyPhaseAction(value)) { + return null; + } + + const parts = [ + value.reason ? formatStrategyDisplayText(value.reason) : null, + value.targetPath ? value.targetPath : null, + value.action ? formatStrategyDisplayText(value.action) : null + ].filter((part): part is string => Boolean(part)); + + return parts.length > 0 ? parts.join(" · ") : null; +} + +function getStrategyPhaseActionKey(value: string | StrategyPhaseAction, prefix: string, index: number) { + if (isStrategyPhaseAction(value)) { + return `${prefix}:${value.title}:${value.targetPath ?? "none"}:${value.action ?? "none"}:${index}`; + } + + return `${prefix}:${value}:${index}`; +} + type SeoChartTone = "good" | "neutral" | "problem" | "warning"; type SeoChartDatum = { @@ -538,12 +604,41 @@ type SeoMatrixDatum = { type AnchorReviewAnchorView = AnchorReviewContract["anchors"][number]; type AnchorReviewFilter = "all" | "approved" | "disabled" | "pending" | "proposed"; +type AnchorReviewDraftDecisionMap = Record; type KeywordCleaningItemView = KeywordCleaningContract["items"][number]; type KeywordMapItemView = KeywordMapContract["items"][number]; type KeywordPagePlanStatus = "blocked" | "ready" | "review"; type KeywordCurationRole = Exclude; type KeywordCurationRoleOverrides = Record>; +type KeywordCurationFreshness = "new" | "old"; +type PendingKeywordSuppress = { + itemId: string; + projectId: string; +}; +type PendingAnchorDelete = { + anchorId: string; + projectId: string; +}; +type PendingKeywordContextSnapshotDialog = + | { + mode: "save"; + projectId: string; + value: string; + } + | { + mode: "rename"; + projectId: string; + snapshotId: string; + value: string; + } + | { + mode: "delete"; + projectId: string; + snapshotId: string; + value: string; + }; type KeywordWorkflowStep = "analysis" | "anchors" | "curation" | "none"; +type CollapsibleKeywordStage = Exclude; type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen"; type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support"; type StrategyKeywordDecisionRole = @@ -931,11 +1026,115 @@ function isMarketWordstatOutdated(market: MarketEnrichmentContract) { ); } +function isMarketAnalysisReady(market: MarketEnrichmentContract) { + const latestJob = market.wordstat.latestJob; + + return Boolean( + market.readiness.seedCount > 0 && + latestJob?.status === "done" && + (latestJob.requestedPhraseCount >= market.readiness.seedCount || market.wordstat.summary.collectedSeedCount > 0) + ); +} + +function isKeywordAnalysisWorkflowActive(workflow: KeywordAnalysisWorkflowContract | null | undefined) { + return Boolean(workflow && ["queued", "running", "waiting"].includes(workflow.status)); +} + +function getKeywordWorkflowStepStatusLabel(status: KeywordAnalysisWorkflowContract["steps"][number]["status"]) { + const labels: Record = { + blocked: "остановлено", + completed: "готово", + failed: "ошибка", + pending: "ожидает", + running: "идёт", + waiting: "ждём" + }; + + return labels[status]; +} + +function getKeywordAnalysisProcessCopy( + market: MarketEnrichmentContract, + isRunning: boolean, + workflow?: KeywordAnalysisWorkflowContract | null +) { + if (workflow) { + const activeStep = workflow.steps.find((step) => step.id === workflow.activeStepId) ?? null; + const reason = activeStep?.reason ?? workflow.reason; + + if (workflow.status === "blocked" || workflow.status === "failed") { + return { + detail: reason ?? workflow.summary, + label: workflow.statusLabel, + title: activeStep ? `${activeStep.label} — ${activeStep.status === "failed" ? "ошибка" : "остановлено"}` : workflow.summary, + tone: "blocked" as const + }; + } + + if (workflow.status === "completed") { + return { + detail: workflow.summary, + label: workflow.statusLabel, + title: "Предложение по ключам готово", + tone: "done" as const + }; + } + + return { + detail: activeStep?.detail ?? workflow.summary, + label: workflow.statusLabel, + title: activeStep ? `${activeStep.label} — ${workflow.statusLabel.toLowerCase()}` : workflow.summary, + tone: "active" as const + }; + } + + const latestJobStatus = market.wordstat.latestJob?.status; + + if (isRunning || latestJobStatus === "queued" || latestJobStatus === "running") { + return { + detail: "Система собирает внешние сигналы, обновляет доказательства спроса и готовит очистку ключей.", + label: "Идёт аналитика", + title: "Формируем анализ выбранных ключей", + tone: "active" as const + }; + } + + if (!market.readiness.ontologyReadyForMarket || market.state === "not_ready") { + return { + detail: "Анализ не выполняется: backend не разрешил сбор спроса, пока проектный контекст не подтверждён.", + label: "Остановлено", + title: "Проектный контекст не готов", + tone: "blocked" as const + }; + } + + if (latestJobStatus === "failed" || market.state === "not_configured" || !market.readiness.canCollectWordstat) { + return { + detail: + market.wordstat.latestJob?.errorMessage ?? + "Данные спроса сейчас недоступны на стороне провайдера; пользовательское решение уже принято, дополнительная кнопка здесь не нужна.", + label: "Ожидает данных", + title: "Анализ выбранных ключей не завершён", + tone: "blocked" as const + }; + } + + return { + detail: "Процесс уже запущен после фиксации семантического базиса; итоговые графики и предложения появятся здесь после обновления данных.", + label: "Анализ запущен", + title: "Ожидаем завершения обработки", + tone: "active" as const + }; +} + function getMarketDecision(market: MarketEnrichmentContract) { const approvedCount = market.readiness.seedCount; const collectedCount = market.wordstat.summary.collectedSeedCount; const signalCount = getMarketSignalCount(market); const reviewCount = market.readiness.anchorPendingCount; + const latestJobDone = market.wordstat.latestJob?.status === "done"; + const latestRequestedPhraseCount = market.wordstat.latestJob?.requestedPhraseCount ?? 0; + const currentQueueWasProcessed = latestJobDone && latestRequestedPhraseCount >= approvedCount; if (!market.readiness.ontologyReadyForMarket) { return { @@ -959,17 +1158,23 @@ function getMarketDecision(market: MarketEnrichmentContract) { if (collectedCount === 0) { return { - action: `Запустить сбор Wordstat по ${approvedCount} подтверждённым фразам, потом решать карту фраз.`, - label: "готово к сбору", - summary: "Контекст уже сужен до подтверждённой очереди, но внешних доказательств ещё нет.", + action: currentQueueWasProcessed + ? "Текущий анализ не нашёл рыночного сигнала по выбранным фразам; нужно расширять или пересобирать семантический базис, а не ждать отдельного ручного запуска." + : "Анализ выбранных ключей принят в работу; результаты появятся после завершения обработки очереди.", + label: currentQueueWasProcessed ? "спрос не найден" : "анализ выполняется", + summary: currentQueueWasProcessed + ? "Выбранная очередь обработана, но спрос по ней не подтвердился." + : "Контекст уже сужен до подтверждённой очереди, внешние доказательства ещё готовятся.", tone: "warning" as const }; } if (collectedCount < approvedCount) { return { - action: `Запустить сбор Wordstat для оставшихся ${approvedCount - collectedCount} подтверждённых фраз, затем решать SEO-план.`, - label: "сбор неполный", + action: currentQueueWasProcessed + ? "Часть выбранных фраз не дала рыночного сигнала; в план можно передавать только подтверждённые элементы, остальные оставить на ручной разбор." + : "Анализ очереди ещё не завершён полностью; финальный вывод появится после обновления данных.", + label: currentQueueWasProcessed ? "частичный спрос" : "анализ неполный", summary: `${collectedCount}/${approvedCount} подтверждённых фраз имеют данные Wordstat.`, tone: "warning" as const }; @@ -4139,26 +4344,67 @@ function SeoHeatmapChart({ ); } +function KeywordStageToggleButton({ + isCollapsed, + onToggle +}: { + isCollapsed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + function SeoAnchorReviewWorkspace({ actionKey, activeFilter, anchorReview, + demandCollectionProfile, + draftChangeCount, + draftDecisionCount, + isCollapsed, isBulkApproving, + isDemandProfileDirty, + isSavingDraft, isManualAdding, onDecision, + onDeleteRequest, + onDemandProfileChange, onFilterChange, + onToggleCollapse, onManualAdd, + onSaveDraft, stageProjectId, sectionId }: { actionKey: string | null; activeFilter: AnchorReviewFilter; anchorReview: AnchorReviewContract; + demandCollectionProfile: DemandCollectionProfile; + draftChangeCount: number; + draftDecisionCount: number; + isCollapsed: boolean; isBulkApproving: boolean; + isDemandProfileDirty: boolean; + isSavingDraft: boolean; isManualAdding: boolean; onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void; + onDeleteRequest: (anchor: AnchorReviewAnchorView) => void; + onDemandProfileChange: (profile: DemandCollectionProfile) => void; onFilterChange: (filter: AnchorReviewFilter) => void; + onToggleCollapse: () => void; onManualAdd: () => void; + onSaveDraft: () => void; stageProjectId?: string; sectionId?: string; }) { @@ -4174,7 +4420,7 @@ function SeoAnchorReviewWorkspace({ return (
+ +
-
- {filters.map((filter) => ( - - ))} -
- -
- {anchors.length > 0 ? ( - anchors.map((anchor) => { - const approveActionKey = `${anchor.id}:approved`; - const disableActionKey = `${anchor.id}:disabled`; - const pendingActionKey = `${anchor.id}:pending`; - const isApproveBusy = actionKey === approveActionKey; - const isDisableBusy = actionKey === disableActionKey; - const isPendingBusy = actionKey === pendingActionKey; - - return ( -
-
- {getAnchorDecisionStatusLabel(anchor.decision.status)} - {anchor.phrase} - - {getSeoMarketRoleLabel(anchor.marketRole)} · {anchor.clusterTitle} · {Math.round(anchor.confidence * 100)}% - -

{getAnchorReviewReason(anchor)}

-
-
- {anchor.proposal.sendToWordstat ? "Wordstat предложен" : "без Wordstat"} - {anchor.proposal.sendToSerp ? "SERP предложен" : "SERP нет"} - {anchor.normalizedFrom.slice(0, 2).join(", ") || anchor.source} -
-
- - - -
-
- ); - }) - ) : ( -
- В этом фильтре пусто - Переключи фильтр или верни anchors из другого статуса. + {!isCollapsed ? ( + <> +
+ {filters.map((filter) => ( + + ))}
- )} -
+ +
+ {anchors.length > 0 ? ( + anchors.map((anchor) => { + const approveActionKey = `${anchor.id}:approved`; + const disableActionKey = `${anchor.id}:disabled`; + const pendingActionKey = `${anchor.id}:pending`; + const isApproveBusy = actionKey === approveActionKey; + const isDisableBusy = actionKey === disableActionKey; + const isPendingBusy = actionKey === pendingActionKey; + + return ( +
+
+ {getAnchorDecisionStatusLabel(anchor.decision.status)} + {anchor.phrase} + + {getSeoMarketRoleLabel(anchor.marketRole)} · {anchor.clusterTitle} · {Math.round(anchor.confidence * 100)}% + +

{getAnchorReviewReason(anchor)}

+
+
+ {anchor.proposal.sendToWordstat ? "Wordstat предложен" : "без Wordstat"} + {anchor.proposal.sendToSerp ? "SERP предложен" : "SERP нет"} + {anchor.normalizedFrom.slice(0, 2).join(", ") || anchor.source} +
+
+ + + + +
+
+ ); + }) + ) : ( +
+ В этом фильтре пусто + Переключи фильтр или верни anchors из другого статуса. +
+ )} +
+ +
+
+ Профиль сбора спроса + {demandCollectionProfileCopy[demandCollectionProfile].label} + {demandCollectionProfileCopy[demandCollectionProfile].description} +
+
+ + ariaLabel="Выбрать профиль сбора спроса" + className="anchor-demand-profile-select" + disabled={isBulkApproving || isManualAdding || isSavingDraft || anchorReview.state === "not_ready"} + menuClassName="anchor-demand-profile-menu" + onChange={onDemandProfileChange} + options={demandCollectionProfileOptions} + value={demandCollectionProfile} + /> + {isDemandProfileDirty ? не сохранено : null} +
+
+ + ) : null}
); } @@ -4378,6 +4673,223 @@ function SeoManualAnchorModal({ ); } +function SeoKeywordSuppressModal({ + item, + isSaving, + onClose, + onConfirm, + projectName +}: { + item: KeywordMapItemView; + isSaving: boolean; + onClose: () => void; + onConfirm: () => void; + projectName: string; +}) { + return createPortal( +
{ + if (event.target === event.currentTarget && !isSaving) { + onClose(); + } + }} + role="presentation" + > +
+
+ Распределение ключей + +
+
+ Удалить ключ из распределения + {projectName} +
+
+ {getKeywordCurationRoleLabel(normalizeKeywordCurationRole(item.role))} + {item.phrase} + + {formatCount(item.frequency)} · {getMarketEvidenceStatusLabel(item.evidenceStatus)} ·{" "} + {item.targetPath ?? item.relatedLanding ?? "без страницы"} + +
+

+ Ключ исчезнет из текущей раскладки сразу. После сохранения Stage 3 он не вернётся в распределение этого + прогона и не попадёт в стратегию. +

+
+ + +
+
+
, + document.body + ); +} + +function SeoAnchorDeleteModal({ + anchor, + onClose, + onConfirm, + projectName +}: { + anchor: AnchorReviewAnchorView; + onClose: () => void; + onConfirm: () => void; + projectName: string; +}) { + return createPortal( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + role="presentation" + > +
+
+ Семантический базис + +
+
+ Удалить якорь из базиса + {projectName} +
+
+ {getAnchorDecisionStatusLabel(anchor.decision.status)} + {anchor.phrase} + + {getSeoMarketRoleLabel(anchor.marketRole)} · {anchor.clusterTitle} · {Math.round(anchor.confidence * 100)}% + +
+

+ Якорь исчезнет из Stage 1 и не попадёт в Wordstat. Нажми сохранить Stage 1, чтобы записать удаление. +

+
+ + +
+
+
, + document.body + ); +} + +function SeoKeywordContextSnapshotModal({ + dialog, + isSaving, + onClose, + onSubmit, + onValueChange, + projectName +}: { + dialog: PendingKeywordContextSnapshotDialog; + isSaving: boolean; + onClose: () => void; + onSubmit: (event?: FormEvent) => void; + onValueChange: (value: string) => void; + projectName: string; +}) { + const isDelete = dialog.mode === "delete"; + const cleanValue = dialog.value.trim(); + const title = + dialog.mode === "save" + ? "Сохранить как новый профиль" + : dialog.mode === "rename" + ? "Переименовать контекст" + : "Удалить контекст"; + const actionLabel = dialog.mode === "save" ? "Сохранить как" : dialog.mode === "rename" ? "Переименовать" : "Удалить"; + const busyLabel = dialog.mode === "delete" ? "Удаляю..." : dialog.mode === "rename" ? "Сохраняю..." : "Сохраняю..."; + + return createPortal( +
{ + if (event.target === event.currentTarget && !isSaving) { + onClose(); + } + }} + role="presentation" + > +
{ + event.preventDefault(); + onSubmit(event); + }} + > +
+ Профиль ключей + +
+
+ {title} + {projectName} +
+ {isDelete ? ( + <> +
+ Snapshot + {dialog.value} + Профиль исчезнет из списка сохранённых контекстов, но audit-запись останется в runs. +
+

+ Текущая карта ключей и раскладка на экране не изменятся. Удаляется только сохранённый профиль. +

+ + ) : ( + + )} +
+ + +
+
+
, + document.body + ); +} + function SeoKeywordCleaningLane({ emptyLabel, items, @@ -4429,15 +4941,47 @@ function getKeywordCleaningStage5Candidates(keywordCleaning: KeywordCleaningCont } function KeywordCurationCard({ + freshness = "old", item, + onSuppress, role }: { + freshness?: KeywordCurationFreshness; item: KeywordMapItemView; + onSuppress?: (item: KeywordMapItemView) => void; role: KeywordCurationRole; }) { + const freshnessLabel = freshness === "new" ? "новый ключ после расширения" : "старый ключ из предыдущей карты"; + return ( -
- {getKeywordCurationRoleLabel(role)} +
+ {onSuppress ? ( + + ) : null} +
+ + {getKeywordCurationRoleLabel(role)} +
{item.phrase} {formatCount(item.frequency)} · {getMarketEvidenceStatusLabel(item.evidenceStatus)} · {item.targetPath ?? item.relatedLanding ?? "без страницы"} @@ -4448,18 +4992,22 @@ function KeywordCurationCard({ } function DraggableKeywordCurationCard({ + freshness, isDragging, item, onPointerDragEnd, onPointerDragMove, onPointerDragStart, + onSuppress, role }: { + freshness: KeywordCurationFreshness; isDragging: boolean; item: KeywordMapItemView; onPointerDragEnd: (itemId: string, x: number, y: number) => void; onPointerDragMove: (itemId: string, x: number, y: number) => void; onPointerDragStart: (itemId: string, pointerId: number, x: number, y: number) => void; + onSuppress: (item: KeywordMapItemView) => void; role: KeywordCurationRole; }) { function handlePointerDown(event: ReactPointerEvent) { @@ -4505,26 +5053,30 @@ function DraggableKeywordCurationCard({ onPointerMove={handlePointerMove} onPointerUp={handlePointerEnd} > - + ); } function KeywordCurationColumn({ activeItemId, + freshnessByItemId, isDraggingOver, items, onPointerDragEnd, onPointerDragMove, onPointerDragStart, + onSuppress, role }: { activeItemId: string | null; + freshnessByItemId: Record; isDraggingOver: boolean; items: KeywordMapItemView[]; onPointerDragEnd: (itemId: string, x: number, y: number) => void; onPointerDragMove: (itemId: string, x: number, y: number) => void; onPointerDragStart: (itemId: string, pointerId: number, x: number, y: number) => void; + onSuppress: (item: KeywordMapItemView) => void; role: KeywordCurationRole; }) { const column = keywordCurationColumns.find((item) => item.role === role); @@ -4545,12 +5097,14 @@ function KeywordCurationColumn({ {items.length > 0 ? ( items.map((item) => ( )) @@ -4563,29 +5117,42 @@ function KeywordCurationColumn({ } function KeywordCurationBoard({ + contextSnapshots, isBusy, + isCollapsed, + isExpanding, isSaving, - isSubmitting, keywordMap, + onExpand, onReconfigure, + onToggleCollapse, onRoleChange, onSave, - onSubmit, + onSuppressRequest, overrides, + suppressedItemIds, stageProjectId }: { + contextSnapshots: KeywordContextSnapshotSummary[]; isBusy: boolean; + isCollapsed: boolean; + isExpanding: boolean; isSaving: boolean; - isSubmitting: boolean; keywordMap: KeywordMapContract; + onExpand: () => void; onReconfigure: () => void; + onToggleCollapse: () => void; onRoleChange: (itemId: string, role: KeywordCurationRole) => void; onSave: () => void; - onSubmit: () => void; + onSuppressRequest: (item: KeywordMapItemView) => void; overrides: Record; + suppressedItemIds: string[]; stageProjectId?: string; }) { - const groups = getKeywordCurationGroups(keywordMap, overrides); + const suppressedItemIdSet = new Set(suppressedItemIds); + const visibleKeywordMapItems = keywordMap.items.filter((item) => !suppressedItemIdSet.has(item.id)); + const freshnessSummary = getKeywordCurationFreshness(keywordMap, contextSnapshots, suppressedItemIdSet); + const groups = getKeywordCurationGroups(keywordMap, overrides, suppressedItemIdSet, freshnessSummary.byItemId); const [activeItemId, setActiveItemId] = useState(null); const [pointerDrag, setPointerDrag] = useState<{ itemId: string; @@ -4594,11 +5161,11 @@ function KeywordCurationBoard({ y: number; } | null>(null); const [dropRole, setDropRole] = useState(null); - const activeItem = activeItemId ? keywordMap.items.find((item) => item.id === activeItemId) ?? null : null; + const activeItem = activeItemId ? visibleKeywordMapItems.find((item) => item.id === activeItemId) ?? null : null; const activeRole = activeItem ? getKeywordCurationRoleForItem(activeItem, overrides) : "validate"; - const persistableCount = getKeywordCurationPersistableCount(keywordMap, overrides); - const saveDisabled = isBusy || keywordMap.state !== "draft" || persistableCount === 0; - const submitDisabled = isBusy || keywordMap.state !== "draft" || persistableCount === 0; + const persistableCount = getKeywordCurationPersistableCount(keywordMap, overrides, suppressedItemIdSet); + const saveDisabled = isBusy || isSaving; + const expandDisabled = isBusy || isExpanding || visibleKeywordMapItems.length === 0; function getDropRoleAtPoint(x: number, y: number) { const element = document.elementFromPoint(x, y)?.closest("[data-keyword-drop-role]"); @@ -4626,7 +5193,7 @@ function KeywordCurationBoard({ } function handleKeywordDragEnd(itemId: string, x: number, y: number) { - const activeDragItem = keywordMap.items.find((item) => item.id === itemId); + const activeDragItem = visibleKeywordMapItems.find((item) => item.id === itemId); const nextRole = getDropRoleAtPoint(x, y); setActiveItemId(null); @@ -4671,20 +5238,28 @@ function KeywordCurationBoard({ window.removeEventListener("mousemove", handleGlobalDragMove); window.removeEventListener("mouseup", handleGlobalDragEnd); }; - }, [pointerDrag, keywordMap.items, overrides]); + }, [pointerDrag, visibleKeywordMapItems, overrides]); return ( -
+
Этап 3 · Распределение ключей Модель распределяет, пользователь правит. Неразобранные не участвуют. - Сформировано: {formatDate(keywordMap.generatedAt)} + + Сформировано: {formatDate(keywordMap.generatedAt)} | ключей: {freshnessSummary.totalCount} | обновлено:{" "} + {freshnessSummary.oldCount} | расширено: {freshnessSummary.newCount} +
+
-
- {keywordCurationColumns.map((column) => ( - - ))} -
- {pointerDrag && activeItem - ? createPortal( -
- -
, - document.body - ) - : null} -
- - {keywordMap.state === "draft" - ? `В стратегию уйдут ${persistableCount} фраз с подтверждённым Wordstat evidence; всё в колонке “Неразобранные” останется вне rewrite.` - : "Карта ключей заблокирована backend-гейтами; сначала нужно закрыть блокеры контекста и доказательств."} - - -
+ {!isCollapsed ? ( + <> +
+ {keywordCurationColumns.map((column) => ( + + ))} +
+ {pointerDrag && activeItem + ? createPortal( +
+ +
, + document.body + ) + : null} +
+ + {keywordMap.state === "draft" + ? `В стратегию уйдут ${persistableCount} фраз с подтверждённым Wordstat evidence; всё в колонке “Неразобранные” останется вне rewrite.` + : "Карта ключей заблокирована backend-гейтами; сначала нужно закрыть блокеры контекста и доказательств."} + + +
+ + ) : null}
); } @@ -5442,9 +6027,14 @@ function getAnchorReviewFilterLabel(filter: AnchorReviewFilter) { return labels[filter]; } +function getKeywordStageCollapseKey(projectId: string, stage: CollapsibleKeywordStage) { + return `${projectId}:${stage}`; +} + function getAnchorDecisionStatusLabel(status: AnchorReviewAnchorView["decision"]["status"]) { const labels = { approved: "в Wordstat", + deleted: "удалено", disabled: "скрыто", pending: "на разборе" }; @@ -5467,6 +6057,10 @@ function getAnchorReviewReason(anchor: AnchorReviewAnchorView) { return "Пользователь скрыл якорь перед сбором Wordstat."; } + if (reason.includes("Human deleted") || reason.includes("Пользователь удалил")) { + return "Пользователь удалил якорь из семантического базиса."; + } + if (reason.includes("Human left")) { return "Пользователь оставил якорь на ручном разборе."; } @@ -5478,6 +6072,170 @@ function getAnchorReviewReason(anchor: AnchorReviewAnchorView) { return reason; } +function buildAnchorReviewDraftDecision( + anchor: AnchorReviewAnchorView, + decision: AnchorReviewDecisionInput +): AnchorReviewAnchorView["decision"] { + if (decision.status === "approved") { + const sendToSerp = decision.sendToSerp ?? anchor.proposal.sendToSerp; + const sendToWordstat = decision.sendToWordstat ?? anchor.proposal.sendToWordstat; + + return { + decidedAt: anchor.decision.decidedAt, + decidedBy: anchor.decision.decidedBy, + reason: decision.reason ?? "Изменение раскладки ожидает сохранения.", + sendToSerp, + sendToWordstat, + source: "human", + status: sendToSerp || sendToWordstat ? "approved" : "pending" + }; + } + + if (decision.status === "disabled") { + return { + decidedAt: anchor.decision.decidedAt, + decidedBy: anchor.decision.decidedBy, + reason: decision.reason ?? "Изменение раскладки ожидает сохранения.", + sendToSerp: false, + sendToWordstat: false, + source: "human", + status: "disabled" + }; + } + + if (decision.status === "deleted") { + return { + decidedAt: anchor.decision.decidedAt, + decidedBy: anchor.decision.decidedBy, + reason: decision.reason ?? "Изменение раскладки ожидает сохранения.", + sendToSerp: false, + sendToWordstat: false, + source: "human", + status: "deleted" + }; + } + + return { + decidedAt: anchor.decision.decidedAt, + decidedBy: anchor.decision.decidedBy, + reason: decision.reason ?? "Изменение раскладки ожидает сохранения.", + sendToSerp: false, + sendToWordstat: false, + source: "human", + status: "pending" + }; +} + +function getAnchorDecisionSignature(decision: Pick) { + return `${decision.status}:${decision.sendToWordstat ? "wordstat" : "no-wordstat"}:${decision.sendToSerp ? "serp" : "no-serp"}`; +} + +function isAnchorReviewDraftEquivalentToSaved(anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) { + return getAnchorDecisionSignature(buildAnchorReviewDraftDecision(anchor, decision)) === getAnchorDecisionSignature(anchor.decision); +} + +function buildAnchorReviewDraftWordstatQueue( + anchors: AnchorReviewAnchorView[] +): AnchorReviewContract["wordstatQueue"] { + return anchors + .filter((anchor) => anchor.decision.status === "approved" && anchor.decision.sendToWordstat) + .map((anchor) => ({ + anchorId: anchor.id, + clusterId: anchor.clusterId, + clusterTitle: anchor.clusterTitle, + confidence: anchor.confidence, + id: `anchor-wordstat:${anchor.id}`, + intentGroupId: anchor.intentGroupId, + intentType: anchor.intentType, + marketRole: anchor.marketRole, + normalizedFrom: anchor.normalizedFrom, + phrase: anchor.phrase, + priority: anchor.priority, + reason: anchor.decision.reason || anchor.reason, + sendToSerp: anchor.decision.sendToSerp, + source: anchor.source + })); +} + +function applyAnchorReviewDraft( + anchorReview: AnchorReviewContract, + draftDecisions: AnchorReviewDraftDecisionMap | undefined +): AnchorReviewContract { + if (!draftDecisions || Object.keys(draftDecisions).length === 0) { + return anchorReview; + } + + const nextAnchors = anchorReview.anchors.map((anchor) => { + const draftDecision = draftDecisions[anchor.id]; + + if (!draftDecision) { + return anchor; + } + + return { + ...anchor, + decision: buildAnchorReviewDraftDecision(anchor, draftDecision) + }; + }); + const deletedAnchorById = new Map((anchorReview.deletedAnchors ?? []).map((anchor) => [anchor.id, anchor])); + nextAnchors + .filter((anchor) => anchor.decision.status === "deleted") + .forEach((anchor) => { + deletedAnchorById.set(anchor.id, { + deletedAt: anchor.decision.decidedAt, + id: anchor.id, + key: anchor.key, + phrase: anchor.phrase, + reason: anchor.decision.reason + }); + }); + const anchors = nextAnchors.filter((anchor) => anchor.decision.status !== "deleted"); + const wordstatQueue = buildAnchorReviewDraftWordstatQueue(anchors); + const pendingAnchorCount = anchors.filter((anchor) => anchor.decision.status === "pending").length; + const approvedAnchorCount = anchors.filter((anchor) => anchor.decision.status === "approved").length; + const disabledAnchorCount = anchors.filter((anchor) => anchor.decision.status === "disabled").length; + const state = + anchorReview.state === "not_ready" + ? "not_ready" + : anchors.length === 0 + ? "empty" + : wordstatQueue.length > 0 + ? "approved" + : "needs_review"; + + return { + ...anchorReview, + anchors, + deletedAnchors: Array.from(deletedAnchorById.values()), + readiness: { + ...anchorReview.readiness, + approvedAnchorCount, + disabledAnchorCount, + pendingAnchorCount, + wordstatQueueCount: wordstatQueue.length + }, + state, + wordstatQueue + }; +} + +function getAnchorReviewDraftDecisionList( + anchorReview: AnchorReviewContract | null, + draftDecisions: AnchorReviewDraftDecisionMap | undefined +) { + if (!anchorReview || !draftDecisions) { + return []; + } + + const anchorsById = new Map(anchorReview.anchors.map((anchor) => [anchor.id, anchor])); + + return Object.values(draftDecisions).filter((decision) => { + const anchor = anchorsById.get(decision.anchorId); + + return Boolean(anchor && !isAnchorReviewDraftEquivalentToSaved(anchor, decision)); + }); +} + function filterAnchorReviewAnchors(anchors: AnchorReviewAnchorView[], filter: AnchorReviewFilter) { if (filter === "all") { return anchors; @@ -5579,25 +6337,105 @@ function getKeywordCurationRoleForItem(item: KeywordMapItemView, overrides: Reco return overrides[item.id] ?? normalizeKeywordCurationRole(item.role); } -function getKeywordCurationGroups(keywordMap: KeywordMapContract, overrides: Record) { +function normalizeKeywordCurationPhrase(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function getKeywordSnapshotTimestamp(snapshot: KeywordContextSnapshotSummary) { + return Date.parse(snapshot.keywordMapGeneratedAt ?? snapshot.createdAt); +} + +function getKeywordExpansionBaselineSnapshot(keywordMap: KeywordMapContract, snapshots: KeywordContextSnapshotSummary[]) { + const keywordMapTimestamp = Date.parse(keywordMap.generatedAt); + + return snapshots + .filter( + (snapshot) => + snapshot.source === "expansion_checkpoint" && Array.isArray(snapshot.keywordPhrases) && snapshot.keywordPhrases.length > 0 + ) + .filter((snapshot) => { + const snapshotTimestamp = getKeywordSnapshotTimestamp(snapshot); + + return Number.isFinite(snapshotTimestamp) && (!Number.isFinite(keywordMapTimestamp) || snapshotTimestamp <= keywordMapTimestamp); + }) + .sort((left, right) => getKeywordSnapshotTimestamp(right) - getKeywordSnapshotTimestamp(left))[0] ?? null; +} + +function getKeywordCurationFreshness( + keywordMap: KeywordMapContract, + snapshots: KeywordContextSnapshotSummary[], + suppressedItemIdSet: Set = new Set() +) { + const baselineSnapshot = getKeywordExpansionBaselineSnapshot(keywordMap, snapshots); + const baselinePhrases = new Set((baselineSnapshot?.keywordPhrases ?? []).map(normalizeKeywordCurationPhrase)); + const byItemId: Record = {}; + let totalCount = 0; + let newCount = 0; + + for (const item of keywordMap.items) { + if (suppressedItemIdSet.has(item.id)) { + continue; + } + + const freshness = + baselineSnapshot && !baselinePhrases.has(normalizeKeywordCurationPhrase(item.phrase)) ? "new" : "old"; + + byItemId[item.id] = freshness; + totalCount += 1; + + if (freshness === "new") { + newCount += 1; + } + } + + return { + baselineSnapshot, + byItemId, + newCount, + oldCount: totalCount - newCount, + totalCount + }; +} + +function getKeywordCurationGroups( + keywordMap: KeywordMapContract, + overrides: Record, + suppressedItemIdSet: Set = new Set(), + freshnessByItemId: Record = {} +) { const groups = new Map(); keywordCurationColumns.forEach((column) => groups.set(column.role, [])); keywordMap.items.forEach((item) => { + if (suppressedItemIdSet.has(item.id)) { + return; + } + const role = getKeywordCurationRoleForItem(item, overrides); const currentItems = groups.get(role) ?? []; currentItems.push(item); groups.set(role, currentItems); }); + groups.forEach((items) => { + items.sort((left, right) => { + const leftFreshness = freshnessByItemId[left.id] === "new" ? 0 : 1; + const rightFreshness = freshnessByItemId[right.id] === "new" ? 0 : 1; + + return leftFreshness - rightFreshness; + }); + }); + return groups; } function getKeywordCurationRoleOverrides( keywordMap: KeywordMapContract, - overrides: Record + overrides: Record, + suppressedItemIdSet: Set = new Set() ): KeywordMapRoleOverrideInput[] { return keywordMap.items + .filter((item) => !suppressedItemIdSet.has(item.id)) .map((item) => ({ itemId: item.id, role: getKeywordCurationRoleForItem(item, overrides) @@ -5605,8 +6443,16 @@ function getKeywordCurationRoleOverrides( .filter((override) => override.role !== normalizeKeywordCurationRole(keywordMap.items.find((item) => item.id === override.itemId)?.role ?? "validate")); } -function getKeywordCurationPersistableCount(keywordMap: KeywordMapContract, overrides: Record) { +function getKeywordCurationPersistableCount( + keywordMap: KeywordMapContract, + overrides: Record, + suppressedItemIdSet: Set = new Set() +) { return keywordMap.items.filter((item) => { + if (suppressedItemIdSet.has(item.id)) { + return false; + } + const role = getKeywordCurationRoleForItem(item, overrides); return ( @@ -6116,8 +6962,11 @@ function getPatchBlockerScopeItem( function getModelTaskTypeLabel(taskType: SeoModelTaskType) { const labels = { + "seo.business_synthesis": "Бизнес-смысл", + "seo.commercial_demand": "Коммерческий спрос", "seo.context_review": "Проверка контекста", "seo.keyword_cleaning": "Очистка фраз", + "seo.market_frontier_discovery": "Рыночные фронтиры", "seo.normalization": "Нормализация", "seo.serp_interpretation": "Разбор выдачи", "seo.strategy_quality_review": "Проверка стратегии", @@ -8639,6 +9488,7 @@ export function App() { const [contextReviews, setContextReviews] = useState>({}); const [projectOntologies, setProjectOntologies] = useState>({}); const [marketEnrichments, setMarketEnrichments] = useState>({}); + const [keywordAnalysisWorkflows, setKeywordAnalysisWorkflows] = useState>({}); const [keywordCleanings, setKeywordCleanings] = useState>({}); const [keywordMaps, setKeywordMaps] = useState>({}); const [seoStrategies, setSeoStrategies] = useState>({}); @@ -8688,14 +9538,31 @@ export function App() { const [busyProjectId, setBusyProjectId] = useState(null); const [pendingDeleteProjectId, setPendingDeleteProjectId] = useState(null); const [scanningProjectId, setScanningProjectId] = useState(null); + const [confirmingConfigurationProjectId, setConfirmingConfigurationProjectId] = useState(null); + const [keywordBasisStartedAts, setKeywordBasisStartedAts] = useState>({}); + const [keywordBasisNowMs, setKeywordBasisNowMs] = useState(() => Date.now()); const [analyzingProjectId, setAnalyzingProjectId] = useState(null); const [reviewingContextProjectId, setReviewingContextProjectId] = useState(null); const [approvingAnchorReviewProjectId, setApprovingAnchorReviewProjectId] = useState(null); const [anchorReviewActionKey, setAnchorReviewActionKey] = useState(null); + const [anchorReviewDraftDecisions, setAnchorReviewDraftDecisions] = useState>({}); + const [anchorReviewDemandProfileDrafts, setAnchorReviewDemandProfileDrafts] = useState>({}); + const [savingAnchorReviewDraftProjectId, setSavingAnchorReviewDraftProjectId] = useState(null); const [manualAnchorProjectId, setManualAnchorProjectId] = useState(null); const [manualAnchorPhrase, setManualAnchorPhrase] = useState(""); const [anchorReviewFilters, setAnchorReviewFilters] = useState>({}); + const [collapsedKeywordStages, setCollapsedKeywordStages] = useState>({}); const [keywordCurationRoleOverrides, setKeywordCurationRoleOverrides] = useState({}); + const [keywordCurationSuppressedItemIds, setKeywordCurationSuppressedItemIds] = useState>({}); + const [keywordContextSnapshots, setKeywordContextSnapshots] = useState>({}); + const [activeKeywordContextSnapshotIds, setActiveKeywordContextSnapshotIds] = useState>({}); + const [savingKeywordContextSnapshotProjectId, setSavingKeywordContextSnapshotProjectId] = useState(null); + const [savingKeywordCurationLayoutProjectId, setSavingKeywordCurationLayoutProjectId] = useState(null); + const [pendingKeywordContextSnapshotDialog, setPendingKeywordContextSnapshotDialog] = + useState(null); + const [expandingKeywordVariantsProjectId, setExpandingKeywordVariantsProjectId] = useState(null); + const [pendingKeywordSuppress, setPendingKeywordSuppress] = useState(null); + const [pendingAnchorDelete, setPendingAnchorDelete] = useState(null); const [activeKeywordWorkflowSteps, setActiveKeywordWorkflowSteps] = useState>({}); const [approvingOntologyKey, setApprovingOntologyKey] = useState(null); const [confirmingOntologyKey, setConfirmingOntologyKey] = useState(null); @@ -8829,6 +9696,21 @@ export function App() { setMarketEnrichments(Object.fromEntries(marketEntries)); } + async function loadKeywordAnalysisWorkflows(projectList: ProjectSummary[]) { + const workflowEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestKeywordAnalysisWorkflow(project.id); + return [project.id, result.workflow] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setKeywordAnalysisWorkflows(Object.fromEntries(workflowEntries)); + } + async function loadKeywordCleanings(projectList: ProjectSummary[]) { const keywordCleaningEntries = await Promise.all( projectList.map(async (project) => { @@ -8859,6 +9741,21 @@ export function App() { setKeywordMaps(Object.fromEntries(keywordMapEntries)); } + async function loadKeywordContextSnapshots(projectList: ProjectSummary[]) { + const snapshotEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchKeywordContextSnapshots(project.id); + return [project.id, result.snapshots] as const; + } catch { + return [project.id, []] as const; + } + }) + ); + + setKeywordContextSnapshots(Object.fromEntries(snapshotEntries)); + } + async function loadSeoStrategies(projectList: ProjectSummary[]) { const strategyEntries = await Promise.all( projectList.map(async (project) => { @@ -9111,8 +10008,10 @@ export function App() { if (service.id === "yandex_wordstat") { await Promise.all([ loadMarketEnrichments(projects), + loadKeywordAnalysisWorkflows(projects), loadKeywordCleanings(projects), loadKeywordMaps(projects), + loadKeywordContextSnapshots(projects), loadSeoStrategies(projects), loadRewritePlans(projects), loadMaterializationContracts(projects), @@ -9236,8 +10135,10 @@ export function App() { loadContextReviews(result.projects), loadProjectOntologies(result.projects), loadMarketEnrichments(result.projects), + loadKeywordAnalysisWorkflows(result.projects), loadKeywordCleanings(result.projects), loadKeywordMaps(result.projects), + loadKeywordContextSnapshots(result.projects), loadYandexEvidences(result.projects), loadSerpInterpretations(result.projects), loadSeoStrategies(result.projects), @@ -9289,6 +10190,16 @@ export function App() { void loadProjects(); }, []); + useEffect(() => { + if (Object.keys(keywordBasisStartedAts).length === 0) { + return; + } + + const intervalId = window.setInterval(() => setKeywordBasisNowMs(Date.now()), 1000); + + return () => window.clearInterval(intervalId); + }, [keywordBasisStartedAts]); + useEffect(() => { try { window.localStorage.setItem(SEMANTIC_BLOCK_OVERRIDES_STORAGE_KEY, JSON.stringify(workspaceSemanticBlockOverrides)); @@ -10501,6 +11412,51 @@ export function App() { return marketResult.marketEnrichment.anchorReview; } + function setKeywordStageCollapsed(projectId: string, stage: CollapsibleKeywordStage, isCollapsed: boolean) { + setCollapsedKeywordStages((currentStages) => ({ + ...currentStages, + [getKeywordStageCollapseKey(projectId, stage)]: isCollapsed + })); + } + + function toggleKeywordStageCollapse(projectId: string, stage: CollapsibleKeywordStage) { + setCollapsedKeywordStages((currentStages) => { + const key = getKeywordStageCollapseKey(projectId, stage); + + return { + ...currentStages, + [key]: !currentStages[key] + }; + }); + } + + function syncMarketAnchorReview(projectId: string, anchorReview: AnchorReviewContract) { + setMarketEnrichments((currentEnrichments) => { + const currentMarket = currentEnrichments[projectId]; + + if (!currentMarket) { + return currentEnrichments; + } + + return { + ...currentEnrichments, + [projectId]: { + ...currentMarket, + anchorReview, + demandCollectionProfile: anchorReview.demandCollectionProfile, + readiness: { + ...currentMarket.readiness, + anchorApprovedCount: anchorReview.readiness.approvedAnchorCount, + anchorPendingCount: anchorReview.readiness.pendingAnchorCount, + anchorReviewReady: anchorReview.state === "approved", + anchorWordstatQueueCount: anchorReview.readiness.wordstatQueueCount, + seedCount: anchorReview.readiness.wordstatQueueCount + } + } + }; + }); + } + async function waitForSeoModelTaskCompletion(projectId: string, runId: string) { const startedAt = Date.now(); let latestRun: SeoModelTaskRun | null = null; @@ -10546,6 +11502,22 @@ export function App() { } async function refreshAfterSeoModelTask(project: ProjectSummary, taskType: SeoModelTaskType) { + if (taskType === "seo.context_review") { + const [contextResult, marketResult] = await Promise.all([ + fetchLatestSeoContextReview(project.id), + fetchLatestMarketEnrichment(project.id) + ]); + setContextReviews((currentReviews) => ({ + ...currentReviews, + [project.id]: contextResult.contextReview + })); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + return; + } + if (taskType === "seo.keyword_cleaning") { const [marketResult, keywordCleaningResult, keywordMapResult, strategyResult] = await Promise.all([ fetchLatestMarketEnrichment(project.id), @@ -10626,46 +11598,193 @@ export function App() { setProjectActionProjectId(project.id); try { - const result = await saveAnchorReviewDecisions(project.id); - let keywordCleaningRun: SeoModelTaskRun | null = null; + const canonicalAnchorReview = marketEnrichments[project.id]?.anchorReview ?? null; + const draftDecisions = getAnchorReviewDraftDecisionList(canonicalAnchorReview, anchorReviewDraftDecisions[project.id]); + let anchorReviewForAnalysis = canonicalAnchorReview; + const selectedDemandProfile = + anchorReviewDemandProfileDrafts[project.id] ?? canonicalAnchorReview?.demandCollectionProfile ?? "contextual"; + const isDemandProfileDirty = + Boolean(canonicalAnchorReview) && selectedDemandProfile !== canonicalAnchorReview?.demandCollectionProfile; - if (result.anchorReview.readiness.wordstatQueueCount > 0) { - setEnrichingMarketProjectId(project.id); - await runMarketEnrichment(project.id); - keywordCleaningRun = await runAndWaitSeoModelTask(project, "seo.keyword_cleaning"); + if (!anchorReviewForAnalysis) { + throw new Error("Семантический базис ещё не загружен."); } - await refreshAfterAnchorReviewDecision(project); - setProjectActionMessage( - `Якоря сохранены: в Wordstat ${result.anchorReview.readiness.approvedAnchorCount}, очередь ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. Wordstat обновлён${ - keywordCleaningRun ? `, очистка ключей: ${getModelTaskExecutionStatusLabel(keywordCleaningRun.runStatus)}.` : "." - }` - ); - } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось сохранить anchor review.")); - } finally { - setApprovingAnchorReviewProjectId(null); - setEnrichingMarketProjectId(null); - setAnchorReviewActionKey(null); + + if (draftDecisions.length > 0 || isDemandProfileDirty) { + const draftResult = await saveAnchorReviewDecisions( + project.id, + draftDecisions.length > 0 ? draftDecisions : undefined, + selectedDemandProfile + ); + anchorReviewForAnalysis = draftResult.anchorReview; + setAnchorReviewDraftDecisions((currentDrafts) => ({ + ...currentDrafts, + [project.id]: {} + })); + setAnchorReviewDemandProfileDrafts((currentDrafts) => { + const nextDrafts = { ...currentDrafts }; + delete nextDrafts[project.id]; + return nextDrafts; + }); + syncMarketAnchorReview(project.id, draftResult.anchorReview); + } + + if (anchorReviewForAnalysis.readiness.wordstatQueueCount <= 0) { + throw new Error("Нечего отправлять на анализ: в очереди Wordstat нет выбранных якорей."); + } + + syncMarketAnchorReview(project.id, anchorReviewForAnalysis); + setKeywordStageCollapsed(project.id, "anchors", true); + setKeywordStageCollapsed(project.id, "analysis", false); + setKeywordStageCollapsed(project.id, "curation", true); + setActiveKeywordWorkflowSteps((currentSteps) => ({ + ...currentSteps, + [project.id]: "analysis" + })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: null + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: null + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: null + })); + setKeywordCurationRoleOverrides((currentOverrides) => ({ + ...currentOverrides, + [project.id]: {} + })); + setKeywordCurationSuppressedItemIds((currentSuppressed) => ({ + ...currentSuppressed, + [project.id]: [] + })); + + const workflowResult = await startKeywordAnalysisWorkflow(project.id); + setKeywordAnalysisWorkflows((currentWorkflows) => ({ + ...currentWorkflows, + [project.id]: workflowResult.workflow + })); + const marketResult = await fetchLatestMarketEnrichment(project.id); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + syncMarketAnchorReview(project.id, anchorReviewForAnalysis); + setProjectActionMessage( + `Анализ ключей запущен: ${workflowResult.workflow.statusLabel}. Шаг: ${ + workflowResult.workflow.activeStepLabel ?? "ожидаем следующий шаг" + }.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось отправить семантический базис на анализ.")); + } finally { + setApprovingAnchorReviewProjectId(null); + setAnchorReviewActionKey(null); + } + } + + function handleAnchorReviewDecision(project: ProjectSummary, decision: AnchorReviewDecisionInput) { + const canonicalAnchorReview = marketEnrichments[project.id]?.anchorReview ?? null; + const canonicalAnchor = canonicalAnchorReview?.anchors.find((anchor) => anchor.id === decision.anchorId) ?? null; + + if (!canonicalAnchor) { + setProjectActionError("Не удалось найти якорь в текущей раскладке."); + return; } + + setProjectActionError(null); + setProjectActionProjectId(project.id); + setAnchorReviewDraftDecisions((currentDrafts) => { + const projectDraft = currentDrafts[project.id] ?? {}; + const nextProjectDraft = { ...projectDraft }; + + if (isAnchorReviewDraftEquivalentToSaved(canonicalAnchor, decision)) { + delete nextProjectDraft[decision.anchorId]; + } else { + nextProjectDraft[decision.anchorId] = decision; + } + + return { + ...currentDrafts, + [project.id]: nextProjectDraft + }; + }); + setProjectActionMessage(`Изменение раскладки подготовлено: ${getAnchorDecisionStatusLabel(decision.status)}. Нажми сохранить, чтобы записать Stage 1.`); } - async function handleAnchorReviewDecision(project: ProjectSummary, decision: AnchorReviewDecisionInput) { - const decisionKey = `${decision.anchorId}:${decision.status}`; + function handleAnchorReviewDemandProfileChange(project: ProjectSummary, profile: DemandCollectionProfile) { + const canonicalProfile = marketEnrichments[project.id]?.anchorReview?.demandCollectionProfile ?? "contextual"; + + setProjectActionError(null); + setProjectActionProjectId(project.id); + setAnchorReviewDemandProfileDrafts((currentDrafts) => { + const nextDrafts = { ...currentDrafts }; + + if (profile === canonicalProfile) { + delete nextDrafts[project.id]; + } else { + nextDrafts[project.id] = profile; + } + + return nextDrafts; + }); + setProjectActionMessage(`Профиль сбора спроса подготовлен: ${demandCollectionProfileCopy[profile].label}. Нажми сохранить, чтобы записать Stage 1.`); + } + + async function handleSaveAnchorReviewDraft(project: ProjectSummary) { + const canonicalAnchorReview = marketEnrichments[project.id]?.anchorReview ?? null; + const decisions = getAnchorReviewDraftDecisionList(canonicalAnchorReview, anchorReviewDraftDecisions[project.id]); + const selectedDemandProfile = + anchorReviewDemandProfileDrafts[project.id] ?? canonicalAnchorReview?.demandCollectionProfile ?? "contextual"; + const isDemandProfileDirty = + Boolean(canonicalAnchorReview) && selectedDemandProfile !== canonicalAnchorReview?.demandCollectionProfile; - setAnchorReviewActionKey(decisionKey); setProjectActionError(null); - setProjectActionMessage(null); setProjectActionProjectId(project.id); + if (!canonicalAnchorReview) { + setProjectActionError("Семантический базис ещё не загружен."); + return; + } + + if (decisions.length === 0 && !isDemandProfileDirty) { + setProjectActionMessage("В раскладке семантического базиса нет несохранённых изменений."); + return; + } + + setSavingAnchorReviewDraftProjectId(project.id); + setAnchorReviewActionKey(`${project.id}:draft-save`); + try { - const result = await saveAnchorReviewDecisions(project.id, [decision]); + const result = await saveAnchorReviewDecisions( + project.id, + decisions.length > 0 ? decisions : undefined, + selectedDemandProfile + ); + setAnchorReviewDraftDecisions((currentDrafts) => ({ + ...currentDrafts, + [project.id]: {} + })); + setAnchorReviewDemandProfileDrafts((currentDrafts) => { + const nextDrafts = { ...currentDrafts }; + delete nextDrafts[project.id]; + return nextDrafts; + }); + syncMarketAnchorReview(project.id, result.anchorReview); await refreshAfterAnchorReviewDecision(project); + syncMarketAnchorReview(project.id, result.anchorReview); setProjectActionMessage( - `Якорь обновлён: ${getAnchorDecisionStatusLabel(decision.status)}. Очередь Wordstat ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. После ручных изменений обнови Wordstat.` + `Раскладка сохранена: ${decisions.length} изменений${ + isDemandProfileDirty ? `, профиль ${demandCollectionProfileCopy[result.anchorReview.demandCollectionProfile].label}` : "" + }. Очередь Wordstat ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}, скрыто ${result.anchorReview.readiness.disabledAnchorCount}.` ); } catch (error: unknown) { - setProjectActionError(getErrorMessage(error, "Не удалось обновить anchor review.")); + setProjectActionError(getErrorMessage(error, "Не удалось сохранить раскладку семантического базиса.")); } finally { + setSavingAnchorReviewDraftProjectId(null); setAnchorReviewActionKey(null); } } @@ -10687,6 +11806,45 @@ export function App() { setManualAnchorPhrase(""); } + function openAnchorDeleteModal(project: ProjectSummary, anchor: AnchorReviewAnchorView) { + setPendingAnchorDelete({ + anchorId: anchor.id, + projectId: project.id + }); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + } + + function closeAnchorDeleteModal() { + setPendingAnchorDelete(null); + } + + function confirmAnchorDelete() { + if (!pendingAnchorDelete) { + return; + } + + const project = projects.find((candidate) => candidate.id === pendingAnchorDelete.projectId); + const anchor = + marketEnrichments[pendingAnchorDelete.projectId]?.anchorReview?.anchors.find( + (candidate) => candidate.id === pendingAnchorDelete.anchorId + ) ?? null; + + if (!project || !anchor) { + setPendingAnchorDelete(null); + setProjectActionError("Не удалось найти якорь для удаления."); + return; + } + + handleAnchorReviewDecision(project, { + anchorId: anchor.id, + reason: "Пользователь удалил якорь из семантического базиса.", + status: "deleted" + }); + setPendingAnchorDelete(null); + } + async function handleAnchorReviewManualAdd(project: ProjectSummary, event?: FormEvent) { event?.preventDefault(); const normalizedPhrase = manualAnchorPhrase.trim().replace(/\s+/g, " "); @@ -10707,7 +11865,9 @@ export function App() { sendToSerp: true, sendToWordstat: true }); + syncMarketAnchorReview(project.id, result.anchorReview); await refreshAfterAnchorReviewDecision(project); + syncMarketAnchorReview(project.id, result.anchorReview); setProjectActionMessage( `Ручной ключ добавлен: ${normalizedPhrase}. Очередь Wordstat ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}.` ); @@ -10809,7 +11969,7 @@ export function App() { })); invalidateStrategyQualityReview(project.id); setProjectActionMessage( - `Keyword cleaning зафиксирован как Codex/manual evidence: ${savedCleaning.keywordCleaning.readiness.keywordMapCandidateCount} exact-кандидатов. Карта ключей: ${getKeywordMapStateLabel(keywordMapResult.keywordMap.state)} · ${getModelTaskRunStatusLabel(modelTaskResult.modelTaskRun.status)}.` + `Keyword cleaning зафиксирован как Codex/manual evidence: ${savedCleaning.keywordCleaning.readiness.keywordMapCandidateCount} кандидатов для карты. Карта ключей: ${getKeywordMapStateLabel(keywordMapResult.keywordMap.state)} · ${getModelTaskRunStatusLabel(modelTaskResult.modelTaskRun.status)}.` ); } catch (error: unknown) { setProjectActionError(getErrorMessage(error, "Не удалось зафиксировать keyword cleaning review.")); @@ -10978,6 +12138,11 @@ export function App() { setProjectActionError(null); setProjectActionMessage(null); setProjectActionProjectId(project.id); + setKeywordStageCollapsed(project.id, "analysis", false); + setActiveKeywordWorkflowSteps((currentSteps) => ({ + ...currentSteps, + [project.id]: "analysis" + })); try { const result = await runMarketEnrichment(project.id); @@ -11162,6 +12327,353 @@ export function App() { })); } + function handleKeywordCurationSuppressRequest(projectId: string, item: KeywordMapItemView) { + setPendingKeywordSuppress({ + itemId: item.id, + projectId + }); + } + + function closeKeywordSuppressModal() { + if (pendingKeywordSuppress?.projectId && persistingKeywordMapProjectId === pendingKeywordSuppress.projectId) { + return; + } + + setPendingKeywordSuppress(null); + } + + function confirmKeywordSuppress() { + if (!pendingKeywordSuppress) { + return; + } + + const { itemId, projectId } = pendingKeywordSuppress; + + setKeywordCurationSuppressedItemIds((currentSuppressed) => { + const projectSuppressedIds = currentSuppressed[projectId] ?? []; + + if (projectSuppressedIds.includes(itemId)) { + return currentSuppressed; + } + + return { + ...currentSuppressed, + [projectId]: [...projectSuppressedIds, itemId] + }; + }); + setKeywordCurationRoleOverrides((currentOverrides) => { + const projectOverrides = currentOverrides[projectId]; + + if (!projectOverrides || !(itemId in projectOverrides)) { + return currentOverrides; + } + + const nextProjectOverrides = { ...projectOverrides }; + delete nextProjectOverrides[itemId]; + + return { + ...currentOverrides, + [projectId]: nextProjectOverrides + }; + }); + setPendingKeywordSuppress(null); + } + + function getKeywordContextSnapshotCuration(projectId: string, keywordMap: KeywordMapContract | null) { + const suppressedItemIds = keywordCurationSuppressedItemIds[projectId] ?? []; + const suppressedItemIdSet = new Set(suppressedItemIds); + + return { + roleOverrides: keywordMap + ? getKeywordCurationRoleOverrides( + keywordMap, + keywordCurationRoleOverrides[projectId] ?? {}, + suppressedItemIdSet + ) + : [], + suppressedItemIds + }; + } + + async function persistKeywordContextSnapshot( + project: ProjectSummary, + source: "expansion_checkpoint" | "manual_save", + label?: string, + note?: string + ) { + const keywordMap = keywordMaps[project.id] ?? null; + const result = await saveKeywordContextSnapshot(project.id, { + curation: getKeywordContextSnapshotCuration(project.id, keywordMap), + label, + note, + source + }); + + setKeywordContextSnapshots((currentSnapshots) => { + const projectSnapshots = currentSnapshots[project.id] ?? []; + + return { + ...currentSnapshots, + [project.id]: [result.snapshot, ...projectSnapshots.filter((snapshot) => snapshot.id !== result.snapshot.id)] + }; + }); + setActiveKeywordContextSnapshotIds((currentIds) => ({ + ...currentIds, + [project.id]: result.snapshot.id + })); + + return result.snapshot; + } + + async function saveKeywordCurationLayoutToActiveSnapshot(project: ProjectSummary) { + const projectSnapshots = keywordContextSnapshots[project.id] ?? []; + const activeSnapshotId = activeKeywordContextSnapshotIds[project.id] ?? projectSnapshots[0]?.id ?? "current"; + const activeSnapshot = projectSnapshots.find((snapshot) => snapshot.id === activeSnapshotId) ?? null; + + if (!activeSnapshot || activeSnapshotId === "current") { + openSaveKeywordContextSnapshotModal(project); + setProjectActionMessage("Для первого сохранения раскладки нужен snapshot-профиль: используй «Сохранить как…»."); + return; + } + + setSavingKeywordCurationLayoutProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const keywordMap = keywordMaps[project.id] ?? null; + const curation = getKeywordContextSnapshotCuration(project.id, keywordMap); + + if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) { + const changedItemIds = Array.from( + new Set([...curation.suppressedItemIds, ...curation.roleOverrides.map((override) => override.itemId)]) + ); + const curationResult = await approveKeywordMapDecisions(project.id, { + itemIds: changedItemIds, + roleOverrides: curation.roleOverrides, + suppressedItemIds: curation.suppressedItemIds + }); + + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: curationResult.keywordMap + })); + setKeywordCurationRoleOverrides((currentOverrides) => ({ + ...currentOverrides, + [project.id]: {} + })); + setKeywordCurationSuppressedItemIds((currentSuppressed) => ({ + ...currentSuppressed, + [project.id]: [] + })); + } + + const result = await updateKeywordContextSnapshotCuration( + project.id, + activeSnapshot.id, + curation + ); + + setKeywordContextSnapshots((currentSnapshots) => ({ + ...currentSnapshots, + [project.id]: (currentSnapshots[project.id] ?? []).map((snapshot) => + snapshot.id === result.snapshot.id ? result.snapshot : snapshot + ) + })); + setActiveKeywordContextSnapshotIds((currentIds) => ({ + ...currentIds, + [project.id]: result.snapshot.id + })); + setProjectActionMessage(`Раскладка ключей сохранена в профиль: ${result.snapshot.label}.`); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось сохранить раскладку ключей.")); + } finally { + setSavingKeywordCurationLayoutProjectId(null); + } + } + + function openSaveKeywordContextSnapshotModal(project: ProjectSummary) { + setPendingKeywordContextSnapshotDialog({ + mode: "save", + projectId: project.id, + value: `Ключи и спрос · ${new Date().toLocaleString("ru-RU")}` + }); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + } + + function openRenameKeywordContextSnapshotModal(project: ProjectSummary, snapshot: KeywordContextSnapshotSummary) { + setPendingKeywordContextSnapshotDialog({ + mode: "rename", + projectId: project.id, + snapshotId: snapshot.id, + value: snapshot.label + }); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + } + + function openDeleteKeywordContextSnapshotModal(project: ProjectSummary, snapshot: KeywordContextSnapshotSummary) { + setPendingKeywordContextSnapshotDialog({ + mode: "delete", + projectId: project.id, + snapshotId: snapshot.id, + value: snapshot.label + }); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + } + + function closeKeywordContextSnapshotModal() { + if ( + pendingKeywordContextSnapshotDialog?.projectId && + savingKeywordContextSnapshotProjectId === pendingKeywordContextSnapshotDialog.projectId + ) { + return; + } + + setPendingKeywordContextSnapshotDialog(null); + } + + function updateKeywordContextSnapshotDialogValue(value: string) { + setPendingKeywordContextSnapshotDialog((currentDialog) => (currentDialog ? { ...currentDialog, value } : currentDialog)); + } + + async function submitKeywordContextSnapshotDialog() { + const dialog = pendingKeywordContextSnapshotDialog; + + if (!dialog) { + return; + } + + const project = projects.find((candidate) => candidate.id === dialog.projectId); + + if (!project) { + setProjectActionError("Не удалось найти проект для snapshot-профиля."); + setPendingKeywordContextSnapshotDialog(null); + return; + } + + setSavingKeywordContextSnapshotProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + if (dialog.mode === "save") { + const snapshot = await persistKeywordContextSnapshot( + project, + "manual_save", + dialog.value, + "Ручное сохранение текущего Stage 5 контекста, раскладки и входных данных модели." + ); + setProjectActionMessage(`Контекст ключей сохранён: ${snapshot.label}.`); + } else if (dialog.mode === "rename") { + const result = await renameKeywordContextSnapshot(project.id, dialog.snapshotId, dialog.value); + setKeywordContextSnapshots((currentSnapshots) => ({ + ...currentSnapshots, + [project.id]: (currentSnapshots[project.id] ?? []).map((snapshot) => + snapshot.id === result.snapshot.id ? result.snapshot : snapshot + ) + })); + setProjectActionMessage(`Контекст переименован: ${result.snapshot.label}.`); + } else { + await deleteKeywordContextSnapshot(project.id, dialog.snapshotId); + setKeywordContextSnapshots((currentSnapshots) => { + const nextSnapshots = (currentSnapshots[project.id] ?? []).filter((snapshot) => snapshot.id !== dialog.snapshotId); + + return { + ...currentSnapshots, + [project.id]: nextSnapshots + }; + }); + setActiveKeywordContextSnapshotIds((currentIds) => ({ + ...currentIds, + [project.id]: currentIds[project.id] === dialog.snapshotId ? "current" : (currentIds[project.id] ?? "current") + })); + setProjectActionMessage(`Контекст удалён: ${dialog.value}.`); + } + setPendingKeywordContextSnapshotDialog(null); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось обновить контекст ключей.")); + } finally { + setSavingKeywordContextSnapshotProjectId(null); + } + } + + async function handleExpandKeywordVariants(project: ProjectSummary) { + setExpandingKeywordVariantsProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const keywordMap = keywordMaps[project.id] ?? null; + const curation = getKeywordContextSnapshotCuration(project.id, keywordMap); + const snapshot = await persistKeywordContextSnapshot( + project, + "expansion_checkpoint", + `Расширение ключей · ${new Date().toLocaleString("ru-RU")}`, + "Checkpoint перед добором дополнительных вариантов: ручная канбан-раскладка не сбрасывается." + ); + + if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) { + const changedItemIds = Array.from( + new Set([...curation.suppressedItemIds, ...curation.roleOverrides.map((override) => override.itemId)]) + ); + const curationResult = await approveKeywordMapDecisions(project.id, { + itemIds: changedItemIds, + roleOverrides: curation.roleOverrides, + suppressedItemIds: curation.suppressedItemIds + }); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: curationResult.keywordMap + })); + } + + setKeywordStageCollapsed(project.id, "analysis", false); + setKeywordStageCollapsed(project.id, "curation", false); + setActiveKeywordWorkflowSteps((currentSteps) => ({ + ...currentSteps, + [project.id]: "analysis" + })); + const workflowResult = await startKeywordAnalysisWorkflow(project.id); + setKeywordAnalysisWorkflows((currentWorkflows) => ({ + ...currentWorkflows, + [project.id]: workflowResult.workflow + })); + const [marketResult, keywordCleaningResult, keywordMapResult] = await Promise.all([ + fetchLatestMarketEnrichment(project.id), + fetchLatestKeywordCleaning(project.id), + fetchLatestKeywordMap(project.id) + ]); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: keywordMapResult.keywordMap + })); + setProjectActionMessage( + `Checkpoint расширения сохранён: ${snapshot.label}. Добор вариантов запущен без сброса текущей раскладки.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось подготовить расширение ключей.")); + } finally { + setExpandingKeywordVariantsProjectId(null); + } + } + function handleKeywordReconfigure(projectId: string) { document.getElementById(`keyword-anchor-review:${projectId}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); } @@ -11171,6 +12683,7 @@ export function App() { input?: { jumpToStrategy?: boolean; roleOverrides?: KeywordMapRoleOverrideInput[]; + suppressedItemIds?: string[]; } ) { setPersistingKeywordMapProjectId(project.id); @@ -11181,7 +12694,8 @@ export function App() { try { const result = await approveKeywordMapDecisions(project.id, { - roleOverrides: input?.roleOverrides + roleOverrides: input?.roleOverrides, + suppressedItemIds: input?.suppressedItemIds }); const [strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ fetchLatestSeoStrategy(project.id), @@ -11218,6 +12732,10 @@ export function App() { ...currentOverrides, [project.id]: {} })); + setKeywordCurationSuppressedItemIds((currentSuppressed) => ({ + ...currentSuppressed, + [project.id]: [] + })); if (input?.jumpToStrategy) { setActiveStageIndex(5); const synthesisRun = await runAndWaitSeoModelTask(project, "seo.strategy_synthesis"); @@ -12669,6 +14187,7 @@ export function App() { ? activeContextReviewRaw : null; const activeMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null; + const activeKeywordAnalysisWorkflow = activeProject ? (keywordAnalysisWorkflows[activeProject.id] ?? null) : null; const activeQuotaProjectId = activeProject?.id ?? null; useEffect(() => { @@ -12690,7 +14209,63 @@ export function App() { }, 60_000); return () => window.clearInterval(intervalId); - }, [activeQuotaProjectId, activeStageIndex]); + }, [activeQuotaProjectId, activeStageIndex]); + + useEffect(() => { + if (activeStageIndex !== 4 || !activeQuotaProjectId || !isKeywordAnalysisWorkflowActive(activeKeywordAnalysisWorkflow)) { + return; + } + + const refreshWorkflow = () => { + void fetchLatestKeywordAnalysisWorkflow(activeQuotaProjectId) + .then(async (result) => { + setKeywordAnalysisWorkflows((currentWorkflows) => ({ + ...currentWorkflows, + [activeQuotaProjectId]: result.workflow + })); + + if (!result.workflow || !isKeywordAnalysisWorkflowActive(result.workflow)) { + const marketResult = await fetchLatestMarketEnrichment(activeQuotaProjectId); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [activeQuotaProjectId]: marketResult.marketEnrichment + })); + + if (result.workflow?.status === "completed") { + const [keywordCleaningResult, keywordMapResult] = await Promise.all([ + fetchLatestKeywordCleaning(activeQuotaProjectId), + fetchLatestKeywordMap(activeQuotaProjectId) + ]); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [activeQuotaProjectId]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [activeQuotaProjectId]: keywordMapResult.keywordMap + })); + } else { + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [activeQuotaProjectId]: null + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [activeQuotaProjectId]: null + })); + } + } + }) + .catch(() => { + // Workflow status refresh is informational; explicit actions surface real failures. + }); + }; + + refreshWorkflow(); + const intervalId = window.setInterval(refreshWorkflow, 5_000); + + return () => window.clearInterval(intervalId); + }, [activeKeywordAnalysisWorkflow?.runId, activeKeywordAnalysisWorkflow?.status, activeQuotaProjectId, activeStageIndex]); const activeKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null; const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null; @@ -12762,11 +14337,13 @@ export function App() { const activeProjectSiteTitle = getProjectSiteTitle(activeProjectScanSummary); const isActiveProjectSelectingPages = activeProject ? selectingPagesProjectId === activeProject.id : false; const isActiveProjectScanning = activeProject ? scanningProjectId === activeProject.id : false; + const isConfirmingActiveProjectConfiguration = activeProject ? confirmingConfigurationProjectId === activeProject.id : false; const canConfirmActiveProjectScope = Boolean( activeProject && activeProjectScanSummary && !isActiveProjectSelectingPages && !isActiveProjectScanning && + !isConfirmingActiveProjectConfiguration && (activeProjectScanScopeCounts?.selectedIndexable ?? 0) > 0 ); const hasActiveProjectScanEntry = activeProject @@ -12996,7 +14573,7 @@ export function App() { }) .sort((first, second) => second.coverage - first.coverage)[0]; const nextStep = - activeCandidate && activeCandidate.coverage >= 0.5 + activeCandidate && activeCandidate.coverage >= 0.08 ? (activeCandidate.section.dataset.keywordStage as KeywordWorkflowStep | undefined) : "none"; @@ -13052,44 +14629,146 @@ export function App() { title: workspaceMode === "dev" ? activeStage.title : "Автоматический SEO-процесс", subtitle: workspaceMode === "dev" ? `Dev Mode · этап ${activeStageIndex + 1}` : "Auto Mode · целевой слой" }; - const activeAnchorReview = activeMarketEnrichment?.anchorReview ?? null; + const activeAnchorReviewRaw = activeMarketEnrichment?.anchorReview ?? null; + const activeAnchorReviewDraft = activeProject ? (anchorReviewDraftDecisions[activeProject.id] ?? {}) : {}; + const activeAnchorReviewDraftCount = getAnchorReviewDraftDecisionList(activeAnchorReviewRaw, activeAnchorReviewDraft).length; + const activeAnchorReview = activeAnchorReviewRaw ? applyAnchorReviewDraft(activeAnchorReviewRaw, activeAnchorReviewDraft) : null; const activeAnchorReviewReady = Boolean(activeMarketEnrichment?.readiness.anchorReviewReady); const activeKeywordWordstatOutdated = activeMarketEnrichment ? isMarketWordstatOutdated(activeMarketEnrichment) : false; - const activeKeywordWordstatFresh = Boolean( - activeMarketEnrichment && - activeMarketEnrichment.readiness.seedCount > 0 && - activeMarketEnrichment.wordstat.latestJob?.status === "done" && - activeMarketEnrichment.wordstat.summary.collectedSeedCount >= activeMarketEnrichment.readiness.seedCount + const activeKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(activeKeywordAnalysisWorkflow); + const activeKeywordAnalysisReady = Boolean( + !activeKeywordAnalysisRebuilding && + (activeKeywordAnalysisWorkflow + ? activeKeywordAnalysisWorkflow.status === "completed" + : activeMarketEnrichment && isMarketAnalysisReady(activeMarketEnrichment)) ); const activeKeywordCurationOverrides = activeProject ? (keywordCurationRoleOverrides[activeProject.id] ?? {}) : {}; + const activeKeywordCurationSuppressedIds = activeProject ? (keywordCurationSuppressedItemIds[activeProject.id] ?? []) : []; + const activeKeywordCurationSuppressedSet = new Set(activeKeywordCurationSuppressedIds); const activeKeywordWorkflowStep = activeProject && activeStageIndex === 4 ? (activeKeywordWorkflowSteps[activeProject.id] ?? "anchors") : "anchors"; + const activeAnchorStageCollapsed = activeProject + ? Boolean(collapsedKeywordStages[getKeywordStageCollapseKey(activeProject.id, "anchors")]) + : false; + const activeAnalysisStageCollapsed = activeProject + ? Boolean(collapsedKeywordStages[getKeywordStageCollapseKey(activeProject.id, "analysis")]) + : false; + const activeCurationStageCollapsed = activeProject + ? Boolean(collapsedKeywordStages[getKeywordStageCollapseKey(activeProject.id, "curation")]) + : false; + const activeOpenKeywordStages: CollapsibleKeywordStage[] = []; + + if (activeAnchorReview && !activeAnchorStageCollapsed) { + activeOpenKeywordStages.push("anchors"); + } + + if (activeAnchorReviewReady && !activeAnalysisStageCollapsed) { + activeOpenKeywordStages.push("analysis"); + } + + if (activeKeywordAnalysisReady && activeKeywordMap && !activeCurationStageCollapsed) { + activeOpenKeywordStages.push("curation"); + } + + const activeKeywordActionStep: KeywordWorkflowStep = + activeProject && + activeStageIndex === 4 && + activeKeywordWorkflowStep !== "none" && + activeOpenKeywordStages.includes(activeKeywordWorkflowStep as CollapsibleKeywordStage) + ? activeKeywordWorkflowStep + : activeProject && activeStageIndex === 4 && activeOpenKeywordStages.length === 1 + ? activeOpenKeywordStages[0] + : "none"; const isApprovingActiveAnchorReview = activeProject ? approvingAnchorReviewProjectId === activeProject.id : false; const isEnrichingActiveMarket = activeProject ? enrichingMarketProjectId === activeProject.id : false; + const activeKeywordAnalysisProcessCopy = activeMarketEnrichment + ? getKeywordAnalysisProcessCopy(activeMarketEnrichment, isEnrichingActiveMarket, activeKeywordAnalysisWorkflow) + : null; const isPersistingActiveKeywordMap = activeProject ? persistingKeywordMapProjectId === activeProject.id : false; const activeKeywordStrategyPersistableCount = activeKeywordMap - ? getKeywordCurationPersistableCount(activeKeywordMap, activeKeywordCurationOverrides) + ? getKeywordCurationPersistableCount(activeKeywordMap, activeKeywordCurationOverrides, activeKeywordCurationSuppressedSet) : 0; const canSubmitActiveKeywordStrategy = Boolean( activeProject && activeKeywordMap && activeKeywordMap.state === "draft" && + !activeKeywordAnalysisRebuilding && activeKeywordStrategyPersistableCount > 0 && !isPersistingActiveKeywordMap ); const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy"; + const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : []; + const activeKeywordContextSnapshotId = + activeProject && activeKeywordContextSnapshotList.length > 0 + ? (activeKeywordContextSnapshotIds[activeProject.id] ?? activeKeywordContextSnapshotList[0]?.id ?? "current") + : "current"; + const activeKeywordContextSnapshotOptions: NdcGlassSelectOption[] = [ + { label: "Текущий контекст", value: "current" }, + ...activeKeywordContextSnapshotList.map((snapshot) => ({ + label: snapshot.label, + actions: activeProject + ? [ + { + ariaLabel: `Переименовать ${snapshot.label}`, + icon: , + onClick: () => openRenameKeywordContextSnapshotModal(activeProject, snapshot), + title: "Переименовать" + }, + { + ariaLabel: `Удалить ${snapshot.label}`, + icon: , + onClick: () => openDeleteKeywordContextSnapshotModal(activeProject, snapshot), + title: "Удалить" + } + ] + : undefined, + value: snapshot.id + })) + ]; + const isSavingActiveKeywordContextSnapshot = activeProject + ? savingKeywordContextSnapshotProjectId === activeProject.id + : false; + const activeKeywordContextToolbar = + activeProject && activeStageIndex === 4 ? ( +
+ + setActiveKeywordContextSnapshotIds((currentIds) => ({ + ...currentIds, + [activeProject.id]: snapshotId + })) + } + options={activeKeywordContextSnapshotOptions} + value={activeKeywordContextSnapshotId} + /> + +
+ ) : null; const topbarStageActions = activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( - ) : activeProject && activeStageIndex === 2 && activeProjectWorkspaceScopeItem ? (
@@ -13140,16 +14819,21 @@ export function App() { ))}
- ) : activeProject && activeStageIndex === 4 ? ( + ) : activeProject && activeStageIndex === 4 && activeKeywordActionStep !== "none" ? (
- {activeKeywordWorkflowStep === "curation" && activeKeywordMap ? ( + {activeKeywordActionStep === "curation" && activeKeywordMap ? ( - ) : activeKeywordWorkflowStep === "analysis" && activeAnchorReviewReady ? ( - activeKeywordWordstatFresh ? ( + ) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? ( + activeKeywordAnalysisReady ? ( ) : ( - + + {activeKeywordAnalysisProcessCopy?.tone === "blocked" ? ( + + ) : ( + + )} + {activeKeywordAnalysisProcessCopy?.label ?? (activeKeywordWordstatOutdated ? "Обновляем анализ" : "Анализ запущен")} + ) - ) : activeKeywordWorkflowStep === "anchors" && activeAnchorReview ? ( - ) : null}
@@ -13346,7 +15034,7 @@ export function App() { setIsWorkPanelOpen(true); } - function confirmProjectConfiguration(project: ProjectSummary, scan: ProjectScanSummary) { + async function confirmProjectConfiguration(project: ProjectSummary, scan: ProjectScanSummary) { const scopeCounts = getScanScopeCounts(scan); if (!isScanReadyForFlow(scan) || scopeCounts.selectedIndexable === 0) { @@ -13359,9 +15047,117 @@ export function App() { setProjectActionProjectId(project.id); setProjectActionError(null); setProjectActionMessage( - `Конфигурация подтверждена: ${scopeCounts.selectedIndexable}/${scopeCounts.indexable} посадочных идут в SEO-пайплайн.` + `Конфигурация подтверждена: ${scopeCounts.selectedIndexable}/${scopeCounts.indexable} посадочных идут в SEO-пайплайн. DEV context и базис ключей собираются в фоне.` ); - setActiveStageIndex(2); + setActiveStageIndex(4); + setIsWorkPanelOpen(true); + setConfirmingConfigurationProjectId(project.id); + setKeywordBasisNowMs(Date.now()); + setKeywordBasisStartedAts((currentStartedAts) => ({ + ...currentStartedAts, + [project.id]: Date.now() + })); + setAnalyzingProjectId(project.id); + + try { + const result = await runSemanticAnalysis(project.id); + + setSemanticAnalyses((currentAnalyses) => ({ + ...currentAnalyses, + [project.id]: result.analysis + })); + setContextReviews((currentReviews) => ({ + ...currentReviews, + [project.id]: null + })); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: null + })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: null + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: null + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: null + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: null + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: null + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: null + })); + setActiveSemanticClusterIds((currentIds) => ({ + ...currentIds, + [project.id]: result.analysis.clusters[0]?.id ?? "" + })); + setActiveKeywordWorkflowSteps((currentSteps) => ({ + ...currentSteps, + [project.id]: "anchors" + })); + setAnalyzingProjectId(null); + + const contextRun = await runAndWaitSeoModelTask(project, "seo.context_review"); + await refreshAfterSeoModelTask(project, "seo.context_review"); + const contextResult = await fetchLatestSeoContextReview(project.id); + + if (!contextResult.contextReview || contextResult.contextReview.semanticRunId !== result.analysis.runId) { + throw new Error("AI Workspace Context Review не сохранился для текущего semantic run."); + } + + const businessRun = await runAndWaitSeoModelTask(project, "seo.business_synthesis"); + const commercialRun = await runAndWaitSeoModelTask(project, "seo.commercial_demand"); + const normalizationRun = await runAndWaitSeoModelTask(project, "seo.normalization"); + await refreshAfterSeoModelTask(project, "seo.normalization"); + const [marketResult, statusResult] = await Promise.all([ + fetchLatestMarketEnrichment(project.id), + fetchModelProviderStatus(project.id) + ]); + + setContextReviews((currentReviews) => ({ + ...currentReviews, + [project.id]: contextResult.contextReview + })); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + setModelProviderStatuses((currentStatuses) => ({ + ...currentStatuses, + [project.id]: statusResult.modelProvider + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage( + `DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel( + businessRun.runStatus + )}. Commercial demand: ${getModelTaskExecutionStatusLabel( + commercialRun.runStatus + )}. Семантический базис ключей подготовлен: ${getModelTaskExecutionStatusLabel( + normalizationRun.runStatus + )}. Дальше стоп до ручной фиксации смысловых якорей.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать DEV context и семантический базис после подтверждения.")); + } finally { + setAnalyzingProjectId(null); + setConfirmingConfigurationProjectId(null); + setKeywordBasisStartedAts((currentStartedAts) => { + const nextStartedAts = { ...currentStartedAts }; + delete nextStartedAts[project.id]; + return nextStartedAts; + }); + } } const strategyExportFormats = [ @@ -13406,6 +15202,31 @@ export function App() { ? (projects.find((project) => project.id === manualAnchorProjectId) ?? null) : null; const isSavingManualAnchor = manualAnchorProject ? anchorReviewActionKey === `${manualAnchorProject.id}:manual-anchor` : false; + const pendingAnchorDeleteProject = pendingAnchorDelete + ? (projects.find((project) => project.id === pendingAnchorDelete.projectId) ?? null) + : null; + const pendingAnchorDeleteItem = + pendingAnchorDelete && pendingAnchorDeleteProject + ? (marketEnrichments[pendingAnchorDelete.projectId]?.anchorReview?.anchors.find( + (anchor) => anchor.id === pendingAnchorDelete.anchorId + ) ?? null) + : null; + const pendingKeywordSuppressProject = pendingKeywordSuppress + ? (projects.find((project) => project.id === pendingKeywordSuppress.projectId) ?? null) + : null; + const pendingKeywordSuppressItem = + pendingKeywordSuppress && pendingKeywordSuppressProject + ? (keywordMaps[pendingKeywordSuppress.projectId]?.items.find((item) => item.id === pendingKeywordSuppress.itemId) ?? null) + : null; + const isSavingPendingKeywordSuppress = pendingKeywordSuppress + ? persistingKeywordMapProjectId === pendingKeywordSuppress.projectId + : false; + const pendingKeywordContextSnapshotProject = pendingKeywordContextSnapshotDialog + ? (projects.find((project) => project.id === pendingKeywordContextSnapshotDialog.projectId) ?? null) + : null; + const isSavingPendingKeywordContextSnapshot = pendingKeywordContextSnapshotDialog + ? savingKeywordContextSnapshotProjectId === pendingKeywordContextSnapshotDialog.projectId + : false; return (
) : null} + {pendingAnchorDeleteProject && pendingAnchorDeleteItem ? ( + + ) : null} + {pendingKeywordSuppressProject && pendingKeywordSuppressItem ? ( + + ) : null} + {pendingKeywordContextSnapshotDialog && pendingKeywordContextSnapshotProject ? ( + void submitKeywordContextSnapshotDialog()} + onValueChange={updateKeywordContextSnapshotDialogValue} + projectName={pendingKeywordContextSnapshotProject.name} + /> + ) : null}
@@ -14069,6 +15917,11 @@ export function App() { ); })} + {activeStageIndex === 4 && activeMarketEnrichment?.wordstat.quota ? ( +
+ +
+ ) : null} ) : null} {!activeProject || isWorkPanelOpen ? ( @@ -14080,9 +15933,7 @@ export function App() {
{activeProject ? (
- {activeStageIndex === 4 && activeMarketEnrichment?.wordstat.quota ? ( - - ) : null} + {activeKeywordContextToolbar} {topbarStageActions}
{activeStageIndex === 5 ? ( @@ -14417,16 +16268,40 @@ export function App() { const stageDecisionFrame = stageDecisionFrames[activeStageIndex] ?? null; const marketDecision = marketEnrichment ? getMarketDecision(marketEnrichment) : null; const wordstatOutdated = marketEnrichment ? isMarketWordstatOutdated(marketEnrichment) : false; - const anchorReview = marketEnrichment?.anchorReview ?? null; + const keywordAnalysisWorkflow = keywordAnalysisWorkflows[project.id] ?? null; + const isKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(keywordAnalysisWorkflow); + const anchorReviewDraft = anchorReviewDraftDecisions[project.id] ?? {}; + const rawAnchorReview = marketEnrichment?.anchorReview ?? null; + const anchorReviewDraftCount = getAnchorReviewDraftDecisionList(rawAnchorReview, anchorReviewDraft).length; + const savedDemandCollectionProfile = rawAnchorReview?.demandCollectionProfile ?? "contextual"; + const anchorReviewDemandProfile = + anchorReviewDemandProfileDrafts[project.id] ?? savedDemandCollectionProfile; + const isAnchorReviewDemandProfileDirty = + anchorReviewDemandProfileDrafts[project.id] !== undefined && + anchorReviewDemandProfile !== savedDemandCollectionProfile; + const anchorReviewDraftChangeCount = + anchorReviewDraftCount + (isAnchorReviewDemandProfileDirty ? 1 : 0); + const anchorReview = rawAnchorReview ? applyAnchorReviewDraft(rawAnchorReview, anchorReviewDraft) : null; const anchorReviewReady = Boolean(marketEnrichment?.readiness.anchorReviewReady); - const keywordWordstatFresh = Boolean( - marketEnrichment && - marketEnrichment.readiness.seedCount > 0 && - marketEnrichment.wordstat.latestJob?.status === "done" && - marketEnrichment.wordstat.summary.collectedSeedCount >= marketEnrichment.readiness.seedCount + const keywordAnalysisReady = Boolean( + !isKeywordAnalysisRebuilding && + (keywordAnalysisWorkflow + ? keywordAnalysisWorkflow.status === "completed" + : marketEnrichment && isMarketAnalysisReady(marketEnrichment)) ); const activeAnchorReviewFilter = anchorReviewFilters[project.id] ?? "proposed"; + const isAnchorStageCollapsed = Boolean( + collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "anchors")] + ); + const isAnalysisStageCollapsed = Boolean( + collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "analysis")] + ); + const isCurationStageCollapsed = Boolean( + collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "curation")] + ); const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {}; + const keywordCurationSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? []; + const keywordCurationSuppressedSet = new Set(keywordCurationSuppressedIds); const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : []; const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; const keywordPagePlans = keywordMap ? getKeywordPlanPageSummaries(keywordMap) : []; @@ -14445,15 +16320,21 @@ export function App() { const isReviewingContext = reviewingContextProjectId === project.id; const isApprovingAnchorReview = approvingAnchorReviewProjectId === project.id; const isEnrichingMarket = enrichingMarketProjectId === project.id; - const showKeywordAnalysisPending = Boolean(anchorReviewReady && (isEnrichingMarket || !keywordWordstatFresh)); - const showKeywordDemandAnalysis = Boolean(anchorReviewReady && keywordWordstatFresh && !isEnrichingMarket); + const keywordAnalysisProcessCopy = marketEnrichment + ? getKeywordAnalysisProcessCopy(marketEnrichment, isEnrichingMarket, keywordAnalysisWorkflow) + : null; + const showKeywordAnalysisPending = Boolean( + anchorReviewReady && marketEnrichment && (isKeywordAnalysisRebuilding || isEnrichingMarket || !keywordAnalysisReady) + ); + const showKeywordDemandAnalysis = Boolean( + anchorReviewReady && keywordAnalysisReady && !isKeywordAnalysisRebuilding && !isEnrichingMarket + ); const isInterpretingSerp = interpretingSerpProjectId === project.id; const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id; const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id; const isReviewingKeywordCleaning = reviewingKeywordCleaningProjectId === project.id; const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id; const isSavingKeywordMap = isPersistingKeywordMap && persistingKeywordMapMode === "save"; - const isSubmittingKeywordStrategy = isPersistingKeywordMap && persistingKeywordMapMode === "strategy"; const isBuildingPatch = buildingPatchProjectId === project.id; const isRunningPatchDryRun = patchArtifact ? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}` @@ -14469,6 +16350,16 @@ export function App() { const isRunningContextModelTask = runningModelTaskKey === `${project.id}:seo.context_review`; const isRunningNormalizationModelTask = runningModelTaskKey === `${project.id}:seo.normalization`; const isRunningKeywordCleaningModelTask = runningModelTaskKey === `${project.id}:seo.keyword_cleaning`; + const keywordBasisStartedAt = keywordBasisStartedAts[project.id] ?? null; + const isPreparingKeywordBasis = Boolean( + confirmingConfigurationProjectId === project.id || + isRunningContextModelTask || + isRunningNormalizationModelTask || + keywordBasisStartedAt + ); + const keywordBasisElapsedLabel = keywordBasisStartedAt + ? formatShortDuration(keywordBasisNowMs - keywordBasisStartedAt) + : "≤1м"; const isRunningSerpInterpretationModelTask = runningModelTaskKey === `${project.id}:seo.serp_interpretation`; const isRunningStrategySynthesisModelTask = @@ -14526,27 +16417,27 @@ export function App() { currentStrategyNarrative?.doNow ?? seoStrategy?.siteMaturity.allowedNow ?? seoStrategy?.nextActions ?? - [] - ) - .slice(0, 4) - .map(formatStrategyDisplayText); + [] + ) + .slice(0, 4) + .map(getStrategyPhaseActionTitle); const strategyPrepareActions = ( strategySynthesis?.phaseDecision.prepareNext ?? currentStrategyNarrative?.prepareNext ?? seoStrategy?.siteMaturity.promotionSignals ?? - [] - ) - .slice(0, 4) - .map(formatStrategyDisplayText); + [] + ) + .slice(0, 4) + .map(getStrategyPhaseActionTitle); const strategyHoldActions = ( strategySynthesis?.blockedActions ?? strategySynthesis?.recommendedPath.hold ?? currentStrategyNarrative?.hold ?? seoStrategy?.siteMaturity.holdUntil ?? - [] - ) - .slice(0, 5) - .map(formatStrategyDisplayText); + [] + ) + .slice(0, 5) + .map(getStrategyPhaseActionTitle); const strategyCanPlan = Boolean( seoStrategy && seoStrategy.state !== "blocked" && @@ -16580,26 +18471,43 @@ export function App() {
- {activeContextReview?.provider.mode === "codex_manual" ? "модель" : "резерв"} + + {!activeContextReview + ? "не запущено" + : activeContextReview.provider.mode === "deterministic_fallback" + ? "резерв" + : "модель"} + {activeContextReview?.analystReview.status ?? "не проверено"} Подсказки {activeContextReview?.readiness.normalizationHintCount ?? 0} Запреты {activeContextReview?.readiness.forbiddenClaimCount ?? 0} Пробелы {activeContextReview?.readiness.semanticGapCount ?? 0} Проверка {activeContextReview?.readiness.needsHumanReview ? "да" : "нет"}
- +
+ + +
@@ -17366,10 +19274,24 @@ export function App() { ) : null} - {activeStageIndex === 4 ? ( -
- {semanticAnalysis && !activeContextReview ? ( -
+ {activeStageIndex === 4 ? ( +
+ {isPreparingKeywordBasis ? ( +
+ + Формируем ключи и спрос + + Собираем DEV context, отправляем clean text corpus в AI Workspace и готовим предложение + семантического базиса. Дальше процесс остановится до ручной фиксации якорей. + + + Прошло {keywordBasisElapsedLabel}. Обычно до 8 минут; окно можно оставить открытым. + +
+ ) : ( + <> + {semanticAnalysis && !activeContextReview ? ( +
Перед рыночным спросом нужен Context Review @@ -17391,40 +19313,62 @@ export function App() { {semanticAnalysis && marketEnrichment ? ( <> - {anchorReview ? ( - void handleAnchorReviewDecision(project, decision)} - onFilterChange={(filter) => - setAnchorReviewFilters((currentFilters) => ({ + {anchorReview ? ( + void handleAnchorReviewDecision(project, decision)} + onDeleteRequest={(anchor) => openAnchorDeleteModal(project, anchor)} + onDemandProfileChange={(profile) => + handleAnchorReviewDemandProfileChange(project, profile) + } + onFilterChange={(filter) => + setAnchorReviewFilters((currentFilters) => ({ ...currentFilters, [project.id]: filter })) - } - onManualAdd={() => openManualAnchorModal(project)} - stageProjectId={project.id} - sectionId={`keyword-anchor-review:${project.id}`} - /> + } + onToggleCollapse={() => toggleKeywordStageCollapse(project.id, "anchors")} + onManualAdd={() => openManualAnchorModal(project)} + onSaveDraft={() => void handleSaveAnchorReviewDraft(project)} + stageProjectId={project.id} + sectionId={`keyword-anchor-review:${project.id}`} + /> ) : null} {showKeywordDemandAnalysis ? (
-
- -
- Этап 2 · Анализ выбранных ключей - Wordstat и внутренняя нормализация показывают, какие фразы подтверждены спросом. - Прогон: {formatDate(marketEnrichment.generatedAt)} +
+
+ +
+ Этап 2 · Анализ выбранных ключей + Wordstat и внутренняя нормализация показывают, какие фразы подтверждены спросом. + Прогон: {formatDate(marketEnrichment.generatedAt)} +
-
+
+ toggleKeywordStageCollapse(project.id, "analysis")} + /> +
+
+ {!isAnalysisStageCollapsed ? ( + <>
{getMarketStateLabel(marketEnrichment.state)} @@ -17490,7 +19434,7 @@ export function App() {
- {marketEnrichment.normalization.provider.mode === "codex_manual" ? "модель" : "резерв"} + {marketEnrichment.normalization.provider.mode === "deterministic_fallback" ? "резерв" : "модель"} групп интента {marketEnrichment.normalization.readiness.intentGroupCount} предложено Wordstat {marketEnrichment.normalization.readiness.wordstatApprovedCount} @@ -17521,31 +19465,70 @@ export function App() { ) : null}
+ + ) : null}
) : null} {showKeywordAnalysisPending ? (
-
- 2 этап · анализ выбранных ключей - - Собрано {marketEnrichment.wordstat.summary.collectedSeedCount}/ - {marketEnrichment.readiness.seedCount} фраз из текущей очереди. - - - {isEnrichingMarket - ? "Готовим свежую аналитику: Wordstat, графики спроса и очистка появятся здесь после завершения." - : "Очередь якорей изменилась или ещё не собиралась; графики и очистка появятся только после свежего Wordstat."} - -
-
- ) : null} +
+
+ +
+ Этап 2 · Анализ выбранных ключей + Аналитика спроса, графики и очистка появятся здесь после завершения обработки. + + {isKeywordAnalysisRebuilding + ? `Новый прогон: ${keywordAnalysisWorkflow?.activeStepLabel ?? "готовим очередь"}.` + : `Обработано ${marketEnrichment.wordstat.summary.collectedSeedCount}/${ + marketEnrichment.readiness.seedCount + } фраз из текущей очереди.`} + +
+
+
+ toggleKeywordStageCollapse(project.id, "analysis")} + /> +
+
+ {!isAnalysisStageCollapsed ? ( + <> +
+ {keywordAnalysisProcessCopy?.tone === "blocked" ? ( + + ) : ( + + )} +
+ {keywordAnalysisProcessCopy?.label ?? "Анализ запущен"} + {keywordAnalysisProcessCopy?.title ?? "Ожидаем завершения обработки"} + {keywordAnalysisProcessCopy?.detail ?? "Результаты появятся здесь автоматически."} +
+
+ {keywordAnalysisWorkflow ? ( +
+ {keywordAnalysisWorkflow.steps.map((step) => ( +
+ {step.label} + {getKeywordWorkflowStepStatusLabel(step.status)} + {step.reason ?? step.detail} +
+ ))} +
+ ) : null} + + ) : null} +
+ ) : null} - {showKeywordDemandAnalysis ? ( + {showKeywordDemandAnalysis && !isAnalysisStageCollapsed ? (
+ {!isAnalysisStageCollapsed ? ( + <>
Сигналы последнего Wordstat @@ -17616,7 +19601,7 @@ export function App() { проверить и что выкинуть.
- {keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"} + {keywordCleaning.provider.mode === "deterministic_fallback" ? "резерв" : "модель"} {keywordCleaning.analystReview.status} Stage 5 {keywordCleaning.readiness.keywordMapCandidateCount} Exact {keywordCleaning.readiness.exactEvidenceCount} @@ -17666,7 +19651,7 @@ export function App() {
) : null} - {keywordMap && keywordMap.items.length > 0 ? ( + + ) : null} + {!isKeywordAnalysisRebuilding && keywordMap && keywordMap.items.length > 0 ? ( void handleExpandKeywordVariants(project)} onReconfigure={() => handleKeywordReconfigure(project.id)} onRoleChange={(itemId, role) => handleKeywordCurationRoleChange(project.id, itemId, role)} - onSave={() => - void handleApproveKeywordMapDecisions(project, { - roleOverrides: getKeywordCurationRoleOverrides(keywordMap, keywordCurationOverrides) - }) - } - onSubmit={() => - void handleApproveKeywordMapDecisions(project, { - jumpToStrategy: true, - roleOverrides: getKeywordCurationRoleOverrides(keywordMap, keywordCurationOverrides) - }) - } + onSuppressRequest={(item) => handleKeywordCurationSuppressRequest(project.id, item)} + onToggleCollapse={() => toggleKeywordStageCollapse(project.id, "curation")} + onSave={() => void saveKeywordCurationLayoutToActiveSnapshot(project)} overrides={keywordCurationOverrides} + suppressedItemIds={keywordCurationSuppressedIds} stageProjectId={project.id} /> - ) : ( + ) : !isKeywordAnalysisRebuilding ? (
Карта ключей пока не собрана. Проверь очистку фраз и повтори сбор спроса.
- )} + ) : null} ) : null} @@ -17813,12 +19796,14 @@ export function App() { ) : (
- Сначала нужен semantic analysis: он подготовит темы, посадочные гипотезы и seed phrases - для проверки спроса. -
- )} -
- ) : null} + Сначала нужен semantic analysis: он подготовит темы, посадочные гипотезы и seed phrases + для проверки спроса. +
+ )} + + )} + + ) : null} {activeStageIndex === 6 ? (
@@ -17831,10 +19816,25 @@ export function App() { disabled={ !keywordMap || keywordMap.state !== "draft" || - keywordMap.readiness.persistableItemCount === 0 || + getKeywordCurationPersistableCount( + keywordMap, + keywordCurationOverrides, + keywordCurationSuppressedSet + ) === 0 || isPersistingKeywordMap } - onClick={() => void handleApproveKeywordMapDecisions(project)} + onClick={() => + void handleApproveKeywordMapDecisions(project, { + roleOverrides: keywordMap + ? getKeywordCurationRoleOverrides( + keywordMap, + keywordCurationOverrides, + keywordCurationSuppressedSet + ) + : [], + suppressedItemIds: keywordCurationSuppressedIds + }) + } type="button" > {isPersistingKeywordMap ? : } @@ -17914,7 +19914,7 @@ export function App() { Источник плана правок очистка фраз ·{" "} - {keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "} + {keywordMap.cleaning.providerMode === "deterministic_fallback" ? "резервный режим" : "модельная проверка"} ·{" "} {keywordMap.cleaning.analystStatus}
@@ -18134,14 +20134,14 @@ export function App() { ) : null} ) : ( -
- - Сначала нужны семантический анализ и рыночный спрос: план правок появится как черновик поверх - проверенной онтологии и доказательств. -
- )} -
- ) : null} +
+ + Сначала нужны семантический анализ и рыночный спрос: план правок появится как черновик поверх + проверенной онтологии и доказательств. +
+ )} + + ) : null} {activeStageIndex === 5 ? (
@@ -18150,6 +20150,12 @@ export function App() { Выбираем кластеры, страницы и правила для следующего плана правок. Сайт на этом этапе не меняем. + {seoStrategy ? ( + + Актуальная версия стратегии:{" "} + {formatDate(strategySynthesis?.state === "ready" ? strategySynthesis.generatedAt : seoStrategy.generatedAt)} + + ) : null}
@@ -19194,24 +21200,39 @@ export function App() {
- {(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} -
- ))} + {(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase?.allowedTargets ?? []).slice(0, 5).map((action, actionIndex) => { + const detail = getStrategyPhaseActionDetail(action); + + return ( +
+ сейчас + {getStrategyPhaseActionTitle(action)} + {detail ? {detail} : null} +
+ ); + })} + {(selectedPhaseStrategy?.prepareNext ?? []).slice(0, 4).map((action, actionIndex) => { + const detail = getStrategyPhaseActionDetail(action); + + return ( +
+ готовить + {getStrategyPhaseActionTitle(action)} + {detail ? {detail} : null} +
+ ); + })} + {(selectedPhaseStrategy?.hold ?? selectedStrategyPhase?.deferredTargets ?? []).slice(0, 4).map((action, actionIndex) => { + const detail = getStrategyPhaseActionDetail(action); + + return ( +
+ стоп + {getStrategyPhaseActionTitle(action)} + {detail ? {detail} : null} +
+ ); + })}
@@ -19418,12 +21439,17 @@ export function App() { фаза {selectedStrategyNarrative?.summary ?? strategySynthesis.phaseDecision.summary} - {(selectedStrategyNarrative?.doNow ?? strategySynthesis.phaseDecision.allowedNow).slice(0, 4).map((action) => ( -
- сейчас - {action} -
- ))} + {(selectedStrategyNarrative?.doNow ?? strategySynthesis.phaseDecision.allowedNow).slice(0, 4).map((action, actionIndex) => { + const detail = getStrategyPhaseActionDetail(action); + + return ( +
+ сейчас + {getStrategyPhaseActionTitle(action)} + {detail ? {detail} : null} +
+ ); + })} @@ -19433,18 +21459,30 @@ export function App() { Что нельзя выдавать как текущий маршрут до зрелости сайта.
- {(selectedStrategyNarrative?.prepareNext ?? strategySynthesis.phaseDecision.prepareNext).slice(0, 3).map((action) => ( -
- готовить - {action} -
- ))} - {(selectedStrategyNarrative?.hold ?? strategySynthesis.phaseDecision.hold).slice(0, 5).map((action) => ( -
- стоп - {action} -
- ))} + {(selectedStrategyNarrative?.prepareNext ?? strategySynthesis.phaseDecision.prepareNext) + .slice(0, 3) + .map((action, actionIndex) => { + const detail = getStrategyPhaseActionDetail(action); + + return ( +
+ готовить + {getStrategyPhaseActionTitle(action)} + {detail ? {detail} : null} +
+ ); + })} + {(selectedStrategyNarrative?.hold ?? strategySynthesis.phaseDecision.hold).slice(0, 5).map((action, actionIndex) => { + const detail = getStrategyPhaseActionDetail(action); + + return ( +
+ стоп + {getStrategyPhaseActionTitle(action)} + {detail ? {detail} : null} +
+ ); + })} {(selectedStrategyNarrative?.nextEvidenceTasks ?? []).slice(0, 3).map((action) => (
данные @@ -19472,16 +21510,16 @@ export function App() { {strategySynthesis.recommendedPath.title} {strategySynthesis.recommendedPath.rationale}
- {strategySynthesis.recommendedPath.doNow.slice(0, 4).map((action) => ( -
+ {strategySynthesis.recommendedPath.doNow.slice(0, 4).map((action, actionIndex) => ( +
сейчас - {action} + {getStrategyPhaseActionTitle(action)}
))} - {strategySynthesis.recommendedPath.hold.slice(0, 3).map((action) => ( -
+ {strategySynthesis.recommendedPath.hold.slice(0, 3).map((action, actionIndex) => ( +
стоп - {action} + {getStrategyPhaseActionTitle(action)}
))}
diff --git a/seo_mode/seo_mode/app/src/NdcGlassSelect.tsx b/seo_mode/seo_mode/app/src/NdcGlassSelect.tsx new file mode 100644 index 0000000..2c15f77 --- /dev/null +++ b/seo_mode/seo_mode/app/src/NdcGlassSelect.tsx @@ -0,0 +1,246 @@ +import type { ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +export type NdcGlassSelectOption = { + value: T; + label: string; + disabled?: boolean; + actions?: Array<{ + ariaLabel: string; + disabled?: boolean; + icon: ReactNode; + onClick: () => void; + title?: string; + }>; +}; + +type NdcGlassSelectProps = { + value: T; + options: NdcGlassSelectOption[]; + ariaLabel?: string; + className?: string; + menuClassName?: string; + disabled?: boolean; + onChange: (value: T) => void; +}; + +export function NdcGlassSelect({ + value, + options, + ariaLabel, + className, + menuClassName, + disabled = false, + onChange +}: NdcGlassSelectProps) { + const [open, setOpen] = useState(false); + const [menuRect, setMenuRect] = useState({ top: 0, left: 0, width: 0 }); + const rootRef = useRef(null); + const menuRef = useRef(null); + const selectId = useMemo(() => `ndc-select-${Math.random().toString(16).slice(2)}`, []); + const selected = options.find((option) => option.value === value) ?? options[0]; + + const updateMenuRect = () => { + const root = rootRef.current; + if (!root) { + return; + } + + const rect = root.getBoundingClientRect(); + const valueRect = root.querySelector(".nodedc-select__value")?.getBoundingClientRect(); + const menuMaxHeight = 232; + const bottomTop = rect.bottom + 6; + const top = + bottomTop + menuMaxHeight > window.innerHeight - 12 + ? Math.max(12, rect.top - menuMaxHeight - 6) + : bottomTop; + + setMenuRect({ + top, + left: valueRect?.left ?? rect.left, + width: Math.max(180, valueRect?.width ?? rect.width) + }); + }; + + useEffect(() => { + if (!open) { + return; + } + + updateMenuRect(); + + const handlePointerDown = (event: MouseEvent) => { + const root = rootRef.current; + const menu = menuRef.current; + + if ( + event.target instanceof Node && + root && + !root.contains(event.target) && + (!menu || !menu.contains(event.target)) + ) { + setOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + const handleOtherSelectOpen = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string }>).detail; + + if (detail?.id !== selectId) { + setOpen(false); + } + }; + const handleViewportChange = () => updateMenuRect(); + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener); + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [open, selectId]); + + const openMenu = () => { + if (disabled) { + return; + } + + setOpen((current) => { + if (!current) { + updateMenuRect(); + window.dispatchEvent(new CustomEvent("nodedc-select-open", { detail: { id: selectId } })); + } + + return !current; + }); + }; + + return ( +
+
+
{selected?.label}
+ +
+ + {open + ? createPortal( +
+ {options.map((option) => ( +
{ + if (option.disabled) { + return; + } + + onChange(option.value); + setOpen(false); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + + if (option.disabled) { + return; + } + + onChange(option.value); + setOpen(false); + }} + role="option" + tabIndex={option.disabled ? -1 : 0} + > + {option.label} + {option.actions?.length ? ( + + {option.actions.map((action) => ( + { + event.preventDefault(); + event.stopPropagation(); + + if (!action.disabled) { + action.onClick(); + setOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!action.disabled) { + action.onClick(); + setOpen(false); + } + }} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + role="button" + tabIndex={action.disabled ? -1 : 0} + title={action.title ?? action.ariaLabel} + > + {action.icon} + + ))} + + ) : null} +
+ ))} +
, + document.body + ) + : null} +
+ ); +} diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index 4569fd2..8e97a85 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -336,6 +336,46 @@ export type SemanticAnalysisRun = { wordCount: number; matchedClusters: string[]; }>; + textCorpus: { + schemaVersion: "seo-text-corpus.v1"; + extraction: { + chunkCount: number; + documentCount: number; + mode: "clean_visible_text"; + sourceDocumentCount: number; + totalChars: number; + totalWords: number; + }; + documents: Array<{ + chunkIds: string[]; + id: string; + kind: "page" | "content_json"; + scope: "indexable_page" | "template_block" | "admin_page" | "technical_page" | "content_source"; + selected: boolean; + sourcePath: string; + textCharCount: number; + title: string; + urlPath: string | null; + usedForCoverage: boolean; + wordCount: number; + }>; + chunks: Array<{ + charEnd: number; + charStart: number; + chunkIndex: number; + documentId: string; + id: string; + kind: "page" | "content_json"; + scope: "indexable_page" | "template_block" | "admin_page" | "technical_page" | "content_source"; + selected: boolean; + sourcePath: string; + text: string; + title: string; + totalChunks: number; + urlPath: string | null; + usedForCoverage: boolean; + }>; + }; legacyFindings: Array<{ markerId: string; label: string; @@ -556,7 +596,7 @@ export type SeoContextReviewRun = { runId: string; status: string; provider: { - mode: "codex_manual" | "deterministic_fallback"; + mode: "codex_manual" | "codex_workspace" | "deterministic_fallback"; modelRequired: true; message: string; }; @@ -616,7 +656,7 @@ export type SeoNormalizationContract = { generatedAt: string; state: "not_ready" | "ready"; provider: { - mode: "codex_manual" | "deterministic_fallback"; + mode: "codex_manual" | "codex_workspace" | "deterministic_fallback"; modelRequired: boolean; message: string; }; @@ -737,9 +777,11 @@ export type AnchorReviewDecision = { sendToSerp: boolean; sendToWordstat: boolean; source: "human" | "model" | "system"; - status: "approved" | "disabled" | "pending"; + status: "approved" | "deleted" | "disabled" | "pending"; }; +export type DemandCollectionProfile = "contextual" | "market_wide"; + export type AnchorReviewContract = { schemaVersion: "anchor-review.v1"; projectId: string; @@ -747,6 +789,7 @@ export type AnchorReviewContract = { projectOntologyVersionId: string | null; generatedAt: string; state: "approved" | "empty" | "needs_review" | "not_ready"; + demandCollectionProfile: DemandCollectionProfile; source: { normalizationGeneratedAt: string | null; normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null; @@ -794,6 +837,13 @@ export type AnchorReviewContract = { }; decision: AnchorReviewDecision; }>; + deletedAnchors: Array<{ + deletedAt: string | null; + id: string; + key: string; + phrase: string; + reason: string; + }>; wordstatQueue: Array<{ id: string; anchorId: string; @@ -845,6 +895,7 @@ export type MarketEnrichmentContract = { state: "not_ready" | "not_collected" | "not_configured" | "partial_ready"; normalization: SeoNormalizationContract; anchorReview: AnchorReviewContract; + demandCollectionProfile: DemandCollectionProfile; readiness: { anchorApprovedCount: number; anchorPendingCount: number; @@ -995,6 +1046,115 @@ export type MarketEnrichmentContract = { nextActions: string[]; }; +export type WordstatProbePlanSource = + | "approved_anchor" + | "commercial_demand" + | "market_anchor_expansion" + | "market_frontier_discovery" + | "previous_expansion"; + +export type WordstatProbePlanRejectedStatus = + | "already_collected" + | "already_requested" + | "duplicate_candidate" + | "invalid_phrase" + | "not_selected_budget_or_balance" + | "suppressed"; + +export type WordstatProbePlanSelectedProbe = { + phrase: string; + normalizedPhrase: string; + clusterId: string; + clusterTitle: string; + priority: "high" | "medium" | "low"; + source: WordstatProbePlanSource; + score: number; + reason: string; +}; + +export type WordstatProbePlanWarning = { + code: + | "frontier_missing" + | "high_reuse_blocking" + | "low_diversity" + | "low_selected_count" + | "weak_generic_probes"; + level: "info" | "warning"; + message: string; + examples: string[]; +}; + +export type WordstatProbePlanPreviewContract = { + schemaVersion: "wordstat-probe-plan-preview.v1"; + projectId: string; + semanticRunId: string | null; + generatedAt: string; + demandCollectionProfile: DemandCollectionProfile; + orchestration: { + mode: "backend_orchestrated_contract_pipeline"; + executionMode: "preview_only"; + modelCanCallWordstat: false; + steps: string[]; + }; + provider: { + wordstatStatus: MarketEnrichmentContract["providers"][number]["status"]; + wordstatMode: string; + }; + readiness: { + canExecute: boolean; + blockers: string[]; + probeBudget: number; + candidateCount: number; + selectedCount: number; + }; + memory: { + requestedPhraseCount: number; + collectedPhraseCount: number; + suppressedPhraseCount: number; + frontierSourceTaskId: string | null; + frontierProbeSeedCount: number; + }; + selectedProbes: WordstatProbePlanSelectedProbe[]; + warnings: WordstatProbePlanWarning[]; + candidateSourceCounts: Record; + rejectedSummary: Record; + rejectedSamples: Array; + nextActions: string[]; +}; + +export type KeywordAnalysisWorkflowContract = { + schemaVersion: "keyword-analysis-workflow.v1"; + runId: string; + projectId: string; + semanticRunId: string | null; + generatedAt: string; + status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting"; + statusLabel: string; + activeStepId: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis" | null; + activeStepLabel: string | null; + summary: string; + reason: string | null; + steps: Array<{ + id: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis"; + label: string; + status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting"; + detail: string; + reason: string | null; + startedAt: string | null; + completedAt: string | null; + }>; + artifacts: { + keywordCleaningRunId: string | null; + marketGeneratedAt: string | null; + modelTaskRunId: string | null; + wordstatJobId: string | null; + }; + nextActions: string[]; +}; + export type KeywordCleaningItem = { id: string; phrase: string; @@ -1029,7 +1189,7 @@ export type KeywordCleaningContract = { generatedAt: string; state: "not_ready" | "ready"; provider: { - mode: "codex_manual" | "deterministic_fallback"; + mode: "codex_manual" | "codex_workspace" | "deterministic_fallback"; modelRequired: boolean; message: string; }; @@ -1093,8 +1253,11 @@ export type KeywordCleaningContract = { }; export type SeoModelTaskType = + | "seo.business_synthesis" + | "seo.commercial_demand" | "seo.context_review" | "seo.keyword_cleaning" + | "seo.market_frontier_discovery" | "seo.normalization" | "seo.serp_interpretation" | "seo.strategy_quality_review" @@ -1167,7 +1330,10 @@ export type SeoModelTaskPersistedResult = { schemaVersion: "seo-model-persisted-result.v1"; status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence"; runType: + | "seo_business_synthesis" + | "seo_commercial_demand" | "keyword_cleaning" + | "seo_context_review" | "seo_model_task" | "seo_normalization" | "seo_strategy_quality_review" @@ -1322,7 +1488,7 @@ export type SerpInterpretationContract = { generatedAt: string; state: "not_ready" | "ready"; provider: { - mode: "codex_manual" | "deterministic_fallback"; + mode: "codex_manual" | "codex_workspace" | "deterministic_fallback"; modelRequired: true; message: string; }; @@ -1466,6 +1632,31 @@ export type KeywordMapRoleOverrideInput = { role: KeywordMapRole; }; +export type KeywordContextSnapshotSummary = { + id: string; + label: string; + source: "expansion_checkpoint" | "manual_save"; + createdAt: string; + semanticRunId: string | null; + keywordMapGeneratedAt: string | null; + keywordCount: number; + keywordPhrases: string[]; + suppressedCount: number; + roleOverrideCount: number; +}; + +export type KeywordContextSnapshotInput = { + label?: string; + note?: string; + source?: "expansion_checkpoint" | "manual_save"; + curation?: { + roleOverrides?: KeywordMapRoleOverrideInput[]; + suppressedItemIds?: string[]; + }; +}; + +export type KeywordContextSnapshotCurationInput = NonNullable; + export type SeoStrategyContract = { schemaVersion: "seo-strategy.v1"; projectId: string; @@ -1650,6 +1841,16 @@ export type SeoStrategyContract = { nextActions: string[]; }; +export type StrategyPhaseAction = { + role?: string; + title: string; + action?: string; + reason?: string; + priority?: "high" | "low" | "medium"; + targetPath?: string | null; + evidenceRefs?: string[]; +}; + export type StrategySynthesisContract = { schemaVersion: "seo-strategy-synthesis.v1"; projectId: string; @@ -1658,7 +1859,7 @@ export type StrategySynthesisContract = { generatedAt: string; state: "not_ready" | "ready"; provider: { - mode: "codex_manual" | "deterministic_fallback"; + mode: "codex_manual" | "codex_workspace" | "deterministic_fallback"; modelRequired: true; message: string; }; @@ -1697,7 +1898,13 @@ export type StrategySynthesisContract = { promotionSignals: string[]; phaseRoadmap: SeoStrategyContract["phasePlan"]; }; - phaseStrategies: SeoStrategyContract["phaseStrategies"]; + phaseStrategies: Array< + Omit & { + doNow: Array; + hold: Array; + prepareNext: Array; + } + >; recommendedPath: { id: string; title: string; @@ -1759,7 +1966,7 @@ export type StrategyQualityReviewContract = { generatedAt: string; state: "not_ready" | "ready"; provider: { - mode: "codex_manual" | "deterministic_fallback"; + mode: "codex_manual" | "codex_workspace" | "deterministic_fallback"; modelRequired: true; message: string; }; @@ -2874,6 +3081,16 @@ export async function fetchLatestMarketEnrichment(projectId: string) { return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>; } +export async function fetchWordstatProbePlanPreview(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/market-enrichment/probe-preview`); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось построить Wordstat probe preview: ${response.status}`)); + } + + return response.json() as Promise<{ probePreview: WordstatProbePlanPreviewContract }>; +} + export async function fetchLatestSeoContextReview(projectId: string) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/context-review/latest`); @@ -2981,9 +3198,26 @@ export async function fetchLatestAnchorReview(projectId: string) { return response.json() as Promise<{ anchorReview: AnchorReviewContract }>; } -export async function saveAnchorReviewDecisions(projectId: string, decisions?: AnchorReviewDecisionInput[]) { +export async function saveAnchorReviewDecisions( + projectId: string, + decisions?: AnchorReviewDecisionInput[], + demandCollectionProfile?: DemandCollectionProfile +) { + const payload: { + decisions?: AnchorReviewDecisionInput[]; + demandCollectionProfile?: DemandCollectionProfile; + } = {}; + + if (decisions) { + payload.decisions = decisions; + } + + if (demandCollectionProfile) { + payload.demandCollectionProfile = demandCollectionProfile; + } + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, { - body: JSON.stringify({ decisions }), + body: JSON.stringify(payload), headers: { "Content-Type": "application/json" }, @@ -3051,6 +3285,28 @@ export async function runMarketEnrichment(projectId: string) { return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>; } +export async function fetchLatestKeywordAnalysisWorkflow(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-analysis-workflow/latest`); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось загрузить workflow анализа ключей: ${response.status}`)); + } + + return response.json() as Promise<{ workflow: KeywordAnalysisWorkflowContract | null }>; +} + +export async function startKeywordAnalysisWorkflow(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-analysis-workflow`, { + method: "POST" + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось запустить workflow анализа ключей: ${response.status}`)); + } + + return response.json() as Promise<{ workflow: KeywordAnalysisWorkflowContract }>; +} + export async function fetchLatestYandexEvidence(projectId: string) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence/latest`); @@ -3168,6 +3424,7 @@ export async function approveKeywordMapDecisions( input?: { itemIds?: string[]; roleOverrides?: KeywordMapRoleOverrideInput[]; + suppressedItemIds?: string[]; } ) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, { @@ -3190,6 +3447,78 @@ export async function approveKeywordMapDecisions( }>; } +export async function fetchKeywordContextSnapshots(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось загрузить snapshot-профили ключей: ${response.status}`)); + } + + return response.json() as Promise<{ snapshots: KeywordContextSnapshotSummary[] }>; +} + +export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(input) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось сохранить snapshot ключей: ${response.status}`)); + } + + return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>; +} + +export async function renameKeywordContextSnapshot(projectId: string, snapshotId: string, label: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ label }) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось переименовать snapshot ключей: ${response.status}`)); + } + + return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>; +} + +export async function updateKeywordContextSnapshotCuration( + projectId: string, + snapshotId: string, + curation: KeywordContextSnapshotCurationInput +) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}/curation`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(curation) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось сохранить раскладку ключей: ${response.status}`)); + } + + return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>; +} + +export async function deleteKeywordContextSnapshot(projectId: string, snapshotId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`, { + method: "DELETE" + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось удалить snapshot ключей: ${response.status}`)); + } +} + export async function fetchLatestRewritePlan(projectId: string) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/rewrite-plan/latest`); diff --git a/seo_mode/seo_mode/app/src/main.tsx b/seo_mode/seo_mode/app/src/main.tsx index f258e49..775a526 100644 --- a/seo_mode/seo_mode/app/src/main.tsx +++ b/seo_mode/seo_mode/app/src/main.tsx @@ -1,9 +1,49 @@ -import { StrictMode } from "react"; +import { Component, StrictMode, type ErrorInfo, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +type AppErrorBoundaryProps = { + children: ReactNode; +}; + +type AppErrorBoundaryState = { + error: Error | null; +}; + +class AppErrorBoundary extends Component { + state: AppErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): AppErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("NDC SEO UI render error", error, errorInfo); + } + + render() { + if (this.state.error) { + return ( +
+
+ Интерфейс SEO Mode упал при отрисовке +

{this.state.error.message}

+ +
+
+ ); + } + + return this.props.children; + } +} + createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css index 977afec..568c1ee 100644 --- a/seo_mode/seo_mode/app/src/styles.css +++ b/seo_mode/seo_mode/app/src/styles.css @@ -2748,6 +2748,11 @@ input { line-height: 1.38; } +.stage-6 .market-heading .strategy-stage-run-date { + color: var(--ink); + font-weight: 850; +} + .stage-6 .market-heading > svg { flex: 0 0 auto; width: 22px; @@ -2760,6 +2765,51 @@ input { box-sizing: content-box; } +.app-fatal-state { + display: grid; + min-height: 100vh; + place-items: center; + padding: 24px; + color: var(--ink); + background: linear-gradient(135deg, #f2f2f2, #ffffff); +} + +.app-fatal-state section { + display: grid; + gap: 12px; + width: min(520px, 100%); + padding: 22px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow-soft); +} + +.app-fatal-state strong { + font-size: 18px; +} + +.app-fatal-state p { + margin: 0; + color: var(--muted); + font-size: 13px; + font-weight: 740; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.app-fatal-state button { + justify-self: start; + min-height: 38px; + padding: 0 14px; + color: #fff; + background: var(--ink); + border: 0; + border-radius: 999px; + font-weight: 900; + cursor: pointer; +} + .market-state-card { align-items: center; padding: 10px; @@ -2882,6 +2932,18 @@ input { box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055); } +.keyword-stage-section.collapsed { + gap: 0; +} + +.keyword-stage-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + .keyword-stage-heading { display: flex; align-items: flex-start; @@ -2949,6 +3011,20 @@ input { color: #fff; } +.keyword-stage-icon-action.active { + color: #fff; + background: #e83e8c; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 14px 34px rgba(232, 62, 140, 0.24); +} + +.keyword-stage-toggle svg { + transition: transform 140ms ease; +} + +.keyword-stage-toggle.expanded svg { + transform: rotate(180deg); +} + .keyword-stage-icon-action:disabled { cursor: not-allowed; opacity: 0.52; @@ -2970,6 +3046,145 @@ input { flex: 1 1 auto; } +.keyword-stage-head > .keyword-stage-heading { + flex: 1 1 auto; +} + +.keyword-stage-process-card { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding: 14px; + background: rgba(24, 32, 29, 0.035); + border: 1px solid rgba(24, 32, 29, 0.07); + border-radius: 12px; +} + +.keyword-stage-process-card.active { + background: rgba(232, 62, 140, 0.045); + border-color: rgba(232, 62, 140, 0.16); +} + +.keyword-stage-process-card.blocked { + background: rgba(139, 47, 47, 0.035); + border-color: rgba(139, 47, 47, 0.12); +} + +.keyword-stage-process-card > svg { + flex: 0 0 auto; + color: #e83e8c; +} + +.keyword-stage-process-card.blocked > svg { + color: #8b2f2f; +} + +.keyword-stage-process-card > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.keyword-stage-process-card span { + justify-self: start; + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 9px; + color: #2e5a67; + background: rgba(46, 90, 103, 0.08); + border: 1px solid rgba(46, 90, 103, 0.14); + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.keyword-stage-process-card strong { + min-width: 0; + color: var(--ink); + font-size: 13px; + font-weight: 900; + overflow-wrap: anywhere; +} + +.keyword-stage-process-card small { + min-width: 0; + color: var(--muted); + font-size: 12px; + font-weight: 740; + line-height: 1.38; + overflow-wrap: anywhere; +} + +.keyword-workflow-step-list { + display: grid; + gap: 7px; + margin-top: 10px; +} + +.keyword-workflow-step-list > div { + display: grid; + grid-template-columns: minmax(160px, 0.5fr) auto minmax(220px, 1fr); + align-items: center; + gap: 10px; + min-width: 0; + padding: 8px 10px; + border: 1px solid rgba(24, 32, 29, 0.07); + border-radius: 10px; + background: rgba(255, 255, 255, 0.72); +} + +.keyword-workflow-step-list span, +.keyword-workflow-step-list strong, +.keyword-workflow-step-list small { + min-width: 0; + overflow-wrap: anywhere; +} + +.keyword-workflow-step-list span { + color: var(--ink); + font-size: 12px; + font-weight: 900; +} + +.keyword-workflow-step-list strong { + justify-self: start; + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: rgba(46, 90, 103, 0.08); + color: #2e5a67; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.keyword-workflow-step-list small { + color: var(--muted); + font-size: 11px; + font-weight: 740; +} + +.keyword-workflow-step-list .completed strong { + background: rgba(47, 122, 89, 0.1); + color: #2f7a59; +} + +.keyword-workflow-step-list .blocked strong, +.keyword-workflow-step-list .failed strong { + background: rgba(139, 47, 47, 0.08); + color: #8b2f2f; +} + +.keyword-workflow-step-list .running strong, +.keyword-workflow-step-list .waiting strong { + background: rgba(232, 62, 140, 0.1); + color: #bc2f72; +} + .anchor-review-row > div:first-child span { justify-self: start; min-height: 22px; @@ -3029,11 +3244,11 @@ input { flex: 1 1 auto; } -.keyword-curation-card > span { - justify-self: start; +.keyword-curation-card-meta > span:last-child { min-height: 22px; display: inline-flex; align-items: center; + min-width: 0; padding: 0 9px; color: rgba(8, 8, 10, 0.78); background: rgba(8, 8, 10, 0.07); @@ -3041,6 +3256,7 @@ input { border-radius: 999px; font-size: 11px; font-weight: 900; + overflow-wrap: anywhere; } .keyword-curation-column-head strong, @@ -3146,10 +3362,11 @@ input { } .keyword-curation-card { + position: relative; display: grid; gap: 5px; min-width: 0; - padding: 10px; + padding: 10px 42px 10px 10px; background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(24, 32, 29, 0.08); border-radius: var(--keyword-curation-radius); @@ -3157,6 +3374,40 @@ input { user-select: none; } +.keyword-curation-trash { + position: absolute; + top: 10px; + right: 10px; + display: grid; + width: 28px; + height: 28px; + place-items: center; + padding: 0; + color: #d52978; + background: rgba(255, 255, 255, 0.92); + border: 1.5px solid rgba(213, 41, 120, 0.72); + border-radius: 999px; + box-shadow: 0 8px 20px rgba(232, 62, 140, 0.08); + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.keyword-curation-trash:hover { + color: #ffffff; + background: #e83e8c; + border-color: #e83e8c; + transform: translateY(-1px); +} + +.keyword-curation-trash:focus-visible { + outline: 2px solid rgba(232, 62, 140, 0.32); + outline-offset: 2px; +} + .keyword-curation-drag-preview { position: fixed; left: var(--keyword-drag-x); @@ -3173,6 +3424,29 @@ input { box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22); } +.keyword-curation-card-meta { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.keyword-curation-freshness { + display: inline-block; + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 999px; +} + +.keyword-curation-freshness.old { + background: #d7a11a; +} + +.keyword-curation-freshness.new { + background: #18a957; +} + .keyword-curation-empty { min-height: 88px; display: grid; @@ -3187,6 +3461,115 @@ input { text-align: center; } +.wordstat-probe-preview { + display: grid; + gap: 8px; + min-width: 0; + padding: 10px; + background: rgba(24, 32, 29, 0.035); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: var(--keyword-curation-radius); +} + +.wordstat-probe-preview-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.wordstat-probe-preview-head > div:first-child { + display: grid; + gap: 3px; + min-width: 0; +} + +.wordstat-probe-preview-head span, +.wordstat-probe-preview-head small, +.wordstat-probe-preview-empty, +.wordstat-probe-preview-hash { + min-width: 0; + color: var(--muted); + font-size: 11px; + font-weight: 800; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.wordstat-probe-preview-head strong { + min-width: 0; + color: var(--ink); + font-size: 14px; + font-weight: 920; + overflow-wrap: anywhere; +} + +.wordstat-probe-preview-head .ready, +.wordstat-probe-preview-head .blocked { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.wordstat-probe-preview-head .ready { + color: #0a6a42; + background: rgba(20, 148, 92, 0.1); +} + +.wordstat-probe-preview-head .blocked { + color: #9c2f2f; + background: rgba(210, 66, 66, 0.1); +} + +.wordstat-probe-preview-stats, +.wordstat-probe-preview-warnings, +.wordstat-probe-preview-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.wordstat-probe-preview-stats span, +.wordstat-probe-preview-warnings span, +.wordstat-probe-preview-list span { + display: inline-flex; + align-items: center; + min-height: 24px; + max-width: 100%; + padding: 0 8px; + color: rgba(8, 8, 10, 0.78); + background: rgba(255, 255, 255, 0.74); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 999px; + font-size: 11px; + font-weight: 840; + overflow-wrap: anywhere; +} + +.wordstat-probe-preview-warnings span { + color: #735018; + background: rgba(255, 202, 96, 0.14); + border-color: rgba(170, 115, 20, 0.16); +} + +.wordstat-probe-preview-warnings span.warning { + color: #8d2f24; + background: rgba(220, 74, 56, 0.1); + border-color: rgba(184, 58, 43, 0.16); +} + +.wordstat-probe-preview-list span { + color: var(--ink); + background: rgba(255, 255, 255, 0.9); +} + .keyword-curation-footer { align-items: center; padding-top: 2px; @@ -3236,6 +3619,228 @@ input { font-weight: 900; } +.anchor-demand-profile-card { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); + gap: 16px; + align-items: center; + min-width: 0; + padding: 12px; + background: rgba(24, 32, 29, 0.034); + border: 1px solid rgba(24, 32, 29, 0.07); + border-radius: 10px; +} + +.anchor-demand-profile-card > div:first-child { + display: grid; + gap: 4px; + min-width: 0; +} + +.anchor-demand-profile-card span { + color: #2e5a67; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.anchor-demand-profile-card strong { + color: var(--ink); + font-size: 14px; + font-weight: 900; + line-height: 1.2; +} + +.anchor-demand-profile-card small { + color: var(--muted); + font-size: 12px; + font-weight: 740; + line-height: 1.35; +} + +.anchor-demand-profile-control { + display: grid; + gap: 7px; + justify-items: stretch; + min-width: 0; +} + +.anchor-demand-profile-control > span { + justify-self: end; + padding: 4px 9px; + color: #7a1f4d; + background: rgba(232, 62, 140, 0.1); + border: 1px solid rgba(232, 62, 140, 0.18); + border-radius: 999px; + letter-spacing: 0; + text-transform: none; +} + +.nodedc-select { + position: relative; + min-width: 0; + width: 100%; +} + +.nodedc-select__control { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 8px; + align-items: center; + min-width: 0; +} + +.nodedc-select__value, +.nodedc-select__toggle { + height: 40px; + color: rgba(20, 23, 25, 0.86); + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92), 0 12px 28px rgba(18, 22, 25, 0.08); + outline: none; + transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease; +} + +.nodedc-select__value { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + padding: 0 12px; + font-size: 12px; + font-weight: 820; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodedc-select__toggle { + display: grid; + place-items: center; + padding: 0; + cursor: pointer; +} + +.nodedc-select__value:hover, +.nodedc-select__toggle:hover, +.nodedc-select__toggle[aria-expanded="true"] { + color: rgba(10, 12, 14, 0.94); + background: rgba(255, 255, 255, 0.96); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96), 0 14px 34px rgba(18, 22, 25, 0.1); +} + +.nodedc-select__toggle:focus, +.nodedc-select__toggle:focus-visible, +.nodedc-select__value:focus, +.nodedc-select__value:focus-visible, +.nodedc-select__option:focus, +.nodedc-select__option:focus-visible { + outline: none; + box-shadow: none; +} + +.nodedc-select__chevron { + width: 8px; + height: 8px; + opacity: 0.72; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: translateY(-2px) rotate(45deg); +} + +.nodedc-dropdown-surface.nodedc-select__menu { + z-index: 30000; + display: grid; + gap: 2px; + max-height: 232px; + padding: 7px; + overflow: auto; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 20px; + box-shadow: 0 18px 60px rgba(24, 32, 29, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.8); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); + scrollbar-width: none; +} + +.nodedc-dropdown-surface.nodedc-select__menu::-webkit-scrollbar { + display: none; +} + +.nodedc-dropdown-option.nodedc-select__option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 42px; + padding: 0 12px; + color: rgba(28, 31, 33, 0.74); + font: inherit; + font-size: 12px; + font-weight: 820; + text-align: left; + background: transparent; + border: 0; + border-radius: 14px; + cursor: pointer; +} + +.nodedc-select__option-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodedc-select__option-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; +} + +.nodedc-select__option-action { + display: grid; + width: 26px; + height: 26px; + place-items: center; + color: rgba(10, 12, 14, 0.68); + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 999px; + cursor: pointer; + transition: background 140ms ease, color 140ms ease, transform 140ms ease; +} + +.nodedc-select__option-action:hover, +.nodedc-select__option-action:focus-visible { + color: #e83e8c; + background: rgba(232, 62, 140, 0.1); + transform: translateY(-1px); + outline: none; +} + +.nodedc-select__option-action[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.46; + transform: none; +} + +.nodedc-dropdown-option.nodedc-select__option:hover, +.nodedc-dropdown-option.nodedc-select__option:focus, +.nodedc-dropdown-option.nodedc-select__option[data-selected="true"] { + color: rgba(10, 12, 14, 0.95); + background: rgba(24, 32, 29, 0.065); +} + +.nodedc-dropdown-option.nodedc-select__option[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.54; +} + .anchor-review-workspace-list { display: grid; gap: 8px; @@ -12258,6 +12863,8 @@ textarea:focus { grid-template-columns: 1fr; } + .keyword-stage-head, + .keyword-anchor-stage-head, .keyword-curation-board-head, .keyword-curation-footer { flex-direction: column; @@ -12271,6 +12878,10 @@ textarea:focus { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .anchor-demand-profile-card { + grid-template-columns: 1fr; + } + .anchor-review-row { grid-template-columns: 1fr; align-items: start; @@ -13075,9 +13686,9 @@ textarea:focus { display: grid; place-items: center; padding: 1rem; - background: rgba(10, 12, 14, 0.28); - backdrop-filter: var(--nodedc-glass-panel-blur); - -webkit-backdrop-filter: var(--nodedc-glass-panel-blur); + background: rgba(10, 12, 14, 0.14); + backdrop-filter: blur(18px) saturate(1.04); + -webkit-backdrop-filter: blur(18px) saturate(1.04); } .seo-project-name-modal { @@ -13148,6 +13759,62 @@ textarea:focus { overflow-wrap: anywhere; } +.seo-keyword-suppress-modal { + width: min(36rem, 100%); +} + +.seo-keyword-context-modal { + width: min(38rem, 100%); +} + +.seo-keyword-suppress-preview { + display: grid; + gap: 0.35rem; + min-width: 0; + padding: 0.88rem; + background: rgba(255, 255, 255, 0.74); + border: 1px solid rgba(24, 32, 29, 0.07); + border-radius: 1rem; + box-shadow: 0 12px 32px rgba(24, 32, 29, 0.06); +} + +.seo-keyword-suppress-preview span { + justify-self: start; + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 9px; + color: rgba(8, 8, 10, 0.78); + background: rgba(8, 8, 10, 0.07); + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.seo-keyword-suppress-preview strong { + color: var(--ink); + font-size: 1rem; + font-weight: 900; + line-height: 1.16; + overflow-wrap: anywhere; +} + +.seo-keyword-suppress-preview small, +.seo-keyword-suppress-note { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 740; + line-height: 1.42; + overflow-wrap: anywhere; +} + +.seo-project-name-modal-actions .danger-action { + background: #e83e8c !important; + border-color: #e83e8c !important; + box-shadow: 0 14px 30px rgba(232, 62, 140, 0.22) !important; +} + .seo-project-name-modal-actions { display: flex; justify-content: flex-end; @@ -14297,7 +14964,7 @@ body:has(.seo-launcher-shell) { color: rgba(8, 8, 10, 0.72); } -.seo-stage-5 .wordstat-quota-strip { +.seo-stage-5 .seo-work-panel-toolbar > .wordstat-quota-strip { position: absolute; top: 50%; left: 50%; @@ -14305,6 +14972,76 @@ body:has(.seo-launcher-shell) { transform: translate(-50%, -50%); } +.nodedc-global-wordstat-quota { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; +} + +.nodedc-global-wordstat-quota .wordstat-quota-strip { + width: clamp(14rem, 22vw, 19rem); + min-width: 12.5rem; +} + +.keyword-context-toolbar { + display: inline-grid; + grid-template-columns: minmax(12rem, 17rem) 2.42rem; + align-items: center; + gap: 0.38rem; + min-width: 0; +} + +.keyword-context-profile-select { + width: 100%; +} + +.keyword-context-toolbar .nodedc-select__control { + grid-template-columns: minmax(0, 1fr) 2.42rem; + gap: 0.38rem; +} + +.keyword-context-toolbar .nodedc-select__value, +.keyword-context-toolbar .nodedc-select__toggle { + height: 2.42rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.84); +} + +.keyword-context-toolbar > button { + display: grid; + width: 2.42rem; + height: 2.42rem; + place-items: center; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.86); + color: rgba(10, 12, 14, 0.78); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 10px 28px rgba(24, 32, 29, 0.08); + cursor: pointer; +} + +.keyword-context-toolbar > button:hover:not(:disabled), +.keyword-context-toolbar > button.saving { + background: #e83e8c; + color: #fff; +} + +.keyword-context-toolbar > button:disabled { + cursor: not-allowed; + opacity: 0.68; +} + +.keyword-context-profile-menu { + min-width: 25rem; + max-width: min(31rem, calc(100vw - 1.5rem)); +} + +.keyword-context-profile-menu .nodedc-select__option { + min-height: 48px; + padding-inline: 12px 8px; +} + .wordstat-quota-head, .wordstat-quota-meta { display: flex; @@ -14419,8 +15156,30 @@ body:has(.seo-launcher-shell) { color: #fff; } +.seo-topbar-status-pill { + display: inline-flex; + min-height: 2.34rem; + align-items: center; + justify-content: center; + gap: 0.38rem; + padding: 0 0.86rem; + border-radius: 999px; + background: rgba(232, 62, 140, 0.09); + color: #bc2f72; + font-size: 0.8rem; + font-weight: 860; + letter-spacing: 0; + white-space: nowrap; +} + +.seo-topbar-status-pill.blocked { + background: rgba(139, 47, 47, 0.07); + color: #8b2f2f; +} + @media (max-width: 1360px) { - .seo-stage-5 .seo-topbar-keyword-actions > button { + .seo-stage-5 .seo-topbar-keyword-actions > button, + .seo-stage-5 .seo-topbar-keyword-actions > .seo-topbar-status-pill { width: 3rem; min-width: 3rem; padding: 0; @@ -14428,7 +15187,8 @@ body:has(.seo-launcher-shell) { font-size: 0; } - .seo-stage-5 .seo-topbar-keyword-actions > button svg { + .seo-stage-5 .seo-topbar-keyword-actions > button svg, + .seo-stage-5 .seo-topbar-keyword-actions > .seo-topbar-status-pill svg { width: 1rem; height: 1rem; } @@ -15481,6 +16241,7 @@ body:has(.seo-launcher-shell) { } .seo-launcher-shell.project-open .seo-stage-rail { + grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden !important; border: 1px solid var(--nodedc-glass-outline) !important; background: var(--nodedc-glass-panel-surface-strong) !important; @@ -15511,6 +16272,43 @@ body:has(.seo-launcher-shell) { z-index: 3; } +.seo-launcher-shell.project-open .seo-stage-rail-list { + min-height: 0; +} + +.seo-stage-rail-wordstat { + display: grid; + min-width: 0; + padding: 0.72rem 0.78rem; + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.76); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 14px 34px rgba(24, 32, 29, 0.08); +} + +.seo-stage-rail-wordstat .wordstat-quota-strip { + width: 100%; + min-width: 0; + gap: 0.18rem; +} + +.seo-stage-rail-wordstat .wordstat-quota-head { + font-size: 0.62rem; +} + +.seo-stage-rail-wordstat .wordstat-quota-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.24rem; + font-size: 0.58rem; +} + +.seo-stage-rail-wordstat .wordstat-quota-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .seo-stage-rail-head { display: grid !important; grid-template-columns: minmax(0, 1fr) 2.38rem;