diff --git a/apps/web/src/components/traffic-panel.tsx b/apps/web/src/components/traffic-panel.tsx
index 371bd23..2146cb2 100644
--- a/apps/web/src/components/traffic-panel.tsx
+++ b/apps/web/src/components/traffic-panel.tsx
@@ -42,15 +42,15 @@ export function TrafficPanel({
const performance = (data.ga?.snapshot as GaSummary | null)?.performance;
return (
- {!settingsOnly && performance &&
}
{!settingsOnly && (
)}
+ {!settingsOnly && performance &&
}
{error && (
[...opportunitiesKeys.all, brandId] as const,
};
-/**
- * Opportunities AEO report. The server returns a stored report and regenerates it
- * only when the latest is stale, so this is held for the session (staleTime:
- * Infinity, no refetch-on-focus) rather than refetched.
- */
+/** Saved recommendations are read without invoking the research provider. */
export function useOpportunities(brandId?: string) {
const resolvedBrandId = useResolvedBrandId(brandId);
+ const client = useQueryClient();
const query = useQuery({
queryKey: opportunitiesKeys.detail(resolvedBrandId || ""),
queryFn: () =>
getOpportunitiesFn({
- data: { brandId: resolvedBrandId!, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
+ data: { brandId: resolvedBrandId || "", timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
}),
enabled: !!resolvedBrandId,
staleTime: Number.POSITIVE_INFINITY,
@@ -27,11 +25,28 @@ export function useOpportunities(brandId?: string) {
retry: false,
});
+ const generation = useMutation({
+ mutationFn: () =>
+ generateOpportunitiesFn({
+ data: {
+ brandId: resolvedBrandId || "",
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ idempotencyKey: uuid(),
+ expectedLastEvaluatedAt: query.data?.lastEvaluatedAt ?? null,
+ },
+ }),
+ onSuccess: (data) => client.setQueryData(opportunitiesKeys.detail(resolvedBrandId || ""), data),
+ retry: false,
+ });
+
return {
data: query.data,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch: query.refetch,
+ generate: generation.mutate,
+ isGenerating: generation.isPending,
+ generationError: generation.error,
};
}
diff --git a/apps/web/src/lib/__tests__/nav-active.test.ts b/apps/web/src/lib/__tests__/nav-active.test.ts
index 1c250be..ee57d0b 100644
--- a/apps/web/src/lib/__tests__/nav-active.test.ts
+++ b/apps/web/src/lib/__tests__/nav-active.test.ts
@@ -71,3 +71,19 @@ describe("SEO section navigation", () => {
expect(activeNavHref(rail, BRAND, "keywords")).toBe("");
});
});
+
+describe("SEO and GEO action navigation", () => {
+ const path = `${BRAND}/opportunities`;
+ const rail = [
+ { href: `${path}#search` },
+ { href: `${path}#seo-actions` },
+ { href: path },
+ { href: `${path}#actions` },
+ { href: `${path}#all-actions` },
+ ];
+ it("keeps SEO destinations selected instead of the generic GEO page", () => {
+ for (const hash of ["search", "seo-actions", "actions", "all-actions"])
+ expect(activeNavHref(rail, path, hash)).toBe(`${path}#${hash}`);
+ expect(activeNavHref(rail, path, "recommendations")).toBe(path);
+ });
+});
diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts
index c895d1c..3c7c3b9 100644
--- a/apps/web/src/lib/breadcrumbs.ts
+++ b/apps/web/src/lib/breadcrumbs.ts
@@ -36,7 +36,11 @@ export function crumbsFor(matches: CrumbMatch[], { hash }: { hash: string }): Cr
}
const crumbs = pageCrumbs(match.staticData?.crumb, match.pathname, hash);
// GEO pages sit under their section the way SEO pages do; its overview is the brand's own page.
- if (brandPath && GEO_PAGES.has(match.staticData?.crumb ?? ""))
+ if (
+ brandPath &&
+ GEO_PAGES.has(match.staticData?.crumb ?? "") &&
+ !(match.staticData?.crumb === "Opportunities" && ["search", "seo-actions", "all-actions"].includes(hash))
+ )
return [{ label: "GEO", href: brandPath.replace(/\/$/, "") }, ...crumbs];
return crumbs;
});
@@ -67,6 +71,23 @@ export function useBreadcrumbs(): Crumb[] {
}
function pageCrumbs(label: string | undefined, pathname: string, hash: string): Crumb[] {
+ if (label === "Opportunities" && ["search", "seo-actions", "all-actions"].includes(hash))
+ return [
+ {
+ label: hash === "all-actions" ? "Analytics" : "SEO",
+ href: pathname.replace(/opportunities$/, hash === "all-actions" ? "traffic" : "seo"),
+ },
+ {
+ label:
+ hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "All improvement actions",
+ href: `${pathname}#${hash}`,
+ },
+ ];
+ if (label === "Traffic analysis")
+ return [
+ { label: "Analytics", href: pathname },
+ { label, href: pathname },
+ ];
if (label === "Search performance")
return [
{ label: "SEO", href: pathname.replace(/search-console$/, "seo") },
diff --git a/apps/web/src/lib/i18n/zh.json b/apps/web/src/lib/i18n/zh.json
index 1da39d3..030fc75 100644
--- a/apps/web/src/lib/i18n/zh.json
+++ b/apps/web/src/lib/i18n/zh.json
@@ -1204,5 +1204,82 @@
"Observed at": "数据观测时间",
"Observed {days} days ago": "观测于 {days} 天前",
"Save an opportunity, implement it, then compare matching search periods or AI questions. Observed changes do not prove causation.": "保存优化行动,实施后对比相同口径的搜索周期或 AI 问题;观测到的变化不代表因果关系。",
- "Find search and AI visibility opportunities, review evidence, and track implemented improvements.": "发现搜索与 AI 可见性的优化线索,核查证据并跟踪已实施的改进。"
+ "Find search and AI visibility opportunities, review evidence, and track implemented improvements.": "发现搜索与 AI 可见性的优化线索,核查证据并跟踪已实施的改进。",
+ "Saved recommendations are read without model calls. Generate a new report only when you choose to spend API credits.": "读取已保存的建议不会调用模型;只有主动生成新报告才会消耗 API 额度。",
+ "Could not load saved recommendations.": "无法加载已保存的建议。",
+ "No saved recommendations yet": "尚未生成推荐报告",
+ "Search diagnosis and saved actions are available without generating an AI report.": "无需生成 AI 报告,也可以使用搜索诊断和已保存的行动。",
+ "Open search diagnosis": "打开搜索诊断",
+ "SEO and GEO improvement workflow": "SEO 与 GEO 优化流程",
+ "GEO recommendations": "GEO 优化建议",
+ "Generate recommendations · uses API credits": "生成优化建议 · 消耗 API 额度",
+ "Could not generate recommendations. Check your research connection and retry explicitly.": "无法生成优化建议,请检查研究模型连接后主动重试。",
+ "Loading saved recommendations…": "正在加载已保存的建议…",
+ "GEO actions": "GEO 行动",
+ "SEO actions": "SEO 行动",
+ "Analytics": "数据分析",
+ "Traffic analysis": "流量分析",
+ "All improvement actions": "全部优化行动",
+ "Review acquisition, organic search, AI referrals and crawler requests as separate measurements.": "分别查看渠道获客、自然搜索、AI 来源访问和爬虫请求,各项指标独立统计。",
+ "SEO improvements": "SEO 优化",
+ "GEO opportunities": "GEO 优化机会",
+ "Search diagnosis uses saved search evidence. Refresh sources explicitly, review candidates, then track implemented changes.": "搜索诊断使用已保存的搜索证据。主动刷新数据、审核候选问题,然后跟踪已实施的改动。",
+ "Filter actions by type": "按类型筛选行动",
+ "All actions": "全部行动",
+ "Change in clicks": "点击次数变化",
+ "Compare equal, non-overlapping full periods after implementation, using the Search Console timezone (America/Los_Angeles).": "使用 Search Console 时区(America/Los_Angeles),比较实施后等长、完整且不重叠的时间段。",
+ "Change recorded. After a full comparison period, refresh Search Console in Search diagnosis.": "已记录变更。完整比较周期结束后,请在搜索诊断中刷新 Search Console 数据。",
+ "No SEO actions yet. Review search diagnosis and save a candidate.": "暂无 SEO 行动。请查看搜索诊断并保存候选改进项。",
+ "No GEO actions yet. Collect a baseline, then save a recommendation.": "暂无 GEO 行动。请先采集基线,再保存优化建议。",
+ "No saved actions yet. Save a search diagnosis or an AI visibility recommendation.": "暂无已保存的行动。请保存搜索诊断候选项或 AI 可见性优化建议。",
+ "Baseline period": "基线时间段",
+ "Checking source connections…": "正在检查数据源连接…",
+ "Connect this source in Brand settings.": "请在品牌设置中连接此数据源。",
+ "Connected. Refresh to collect the first analysis evidence.": "已连接。刷新一次,即可采集首份分析证据。",
+ "Could not check source connections.": "暂时无法检查数据源连接。",
+ "Could not load evidence history. This does not mean no evidence exists.": "无法加载证据历史,这不代表没有已保存的证据。",
+ "Data sources": "数据来源",
+ "Evidence view": "证据视图",
+ "Existing summary available. Refresh once to add detailed analysis evidence.": "已有汇总数据。刷新一次,即可补充详细分析证据。",
+ "Google Analytics": "Google Analytics",
+ "Google may omit some rows. Stored evidence is not a complete list.": "Google 可能省略部分数据行,已保存的证据并非完整数据。",
+ "Google organic landing page context": "Google 自然搜索落地页数据",
+ "How to interpret these candidates": "如何理解这些优化候选项",
+ "No analysis evidence has been saved yet. Refresh a connected source to begin.": "尚未保存分析证据。刷新已连接的数据源即可开始。",
+ "No completed SEO research is saved. Open the workbench to run research or view its history.": "尚无已完成的 SEO 研究结果。请打开工作台开展研究或查看历史记录。",
+ "No rows in this evidence view. Missing data is unknown, not zero.": "此证据视图没有数据行。缺失数据表示未知,不等于零。",
+ "Open SEO workbench": "打开 SEO 工作台",
+ "Page content": "页面正文",
+ "Ready to use.": "已就绪。",
+ "Retry": "重试",
+ "SEO action saved. Open it to review the draft and record your changes.": "SEO 行动已保存。打开行动后可审核草稿并记录修改。",
+ "SEO workbench": "SEO 工作台",
+ "Saved analysis is more than seven days old. Refresh when you need current evidence.": "已保存的分析数据超过七天,如需最新证据请刷新。",
+ "Select a property in Brand settings.": "请在品牌设置中选择对应的网站资源。",
+ "Sources without analysis evidence": "尚无分析证据的数据源",
+ "Stored rows": "已保存的数据行",
+ "Technical details": "技术详情",
+ "The last analysis refresh failed. Existing evidence is retained; check sources before retrying.": "上次分析刷新失败,原有证据仍然保留。请检查数据源后重试。",
+ "An analysis refresh is running or was interrupted. Check the latest evidence before starting another refresh.": "有一次分析刷新正在运行或曾被中断。请先检查最新证据,再决定是否重新刷新。",
+ "The saved SEO result belongs to a different website. Run research for this brand.": "已保存的 SEO 结果属于其他网站。请为当前品牌重新开展研究。",
+ "The saved SEO result could not be read. Check the workbench history.": "无法读取已保存的 SEO 结果,请检查工作台历史记录。",
+ "View saved SEO action": "查看已保存的 SEO 行动",
+ "Working…": "正在处理…",
+ "These GA metrics describe the whole landing page, not conversions from this search query. Periods may differ.": "这些 GA 指标对应整个落地页,并非此搜索词带来的转化,统计时间段也可能不同。",
+ "Could not complete this operation. Review source status and saved evidence before retrying.": "未能完成本次操作。请先检查数据源状态和已保存的证据,再决定是否重试。",
+ "Title": "标题",
+ "Page": "页面",
+ "Notes": "说明",
+ "Status": "状态",
+ "Source": "来源",
+ "Captured at": "采集时间",
+ "Research type": "研究类型",
+ "Query parameters": "查询参数",
+ "Previous clicks": "上一周期点击量",
+ "Observed pages": "观测页面数",
+ "Page sessions": "页面会话数",
+ "Page key events": "页面关键事件数",
+ "Page session key event rate": "页面会话关键事件率",
+ "Not enough recent answers to generate a new report. Your saved report is shown below.": "近期回答不足以生成新报告,下面仍展示已保存的报告。",
+ "Acquisition, organic search, AI referral visits and crawler observations.": "渠道获客、自然搜索、AI 来源访问和爬虫观测。"
}
diff --git a/apps/web/src/lib/mcp/tools/analytics.ts b/apps/web/src/lib/mcp/tools/analytics.ts
index da07c70..2279bae 100644
--- a/apps/web/src/lib/mcp/tools/analytics.ts
+++ b/apps/web/src/lib/mcp/tools/analytics.ts
@@ -99,7 +99,7 @@ export const getOpportunities = defineTool({
name: "get_opportunities",
title: "Get the latest opportunities report",
description:
- "The most recent stored opportunities report for a brand: what to write, what to fix, and the risks BeatGEO found. `status` says whether there was enough data to write one.",
+ "The most recent stored opportunities report for a brand: what to write, what to fix, and the risks BeatGEO found. `status` says whether a saved report exists. This read never generates reports or spends provider budget.",
scopes: ["read"],
readOnly: true,
input: { brandId: brandIdArg },
diff --git a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/opportunities.tsx b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/opportunities.tsx
index 01243f4..792b776 100644
--- a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/opportunities.tsx
+++ b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/opportunities.tsx
@@ -1,17 +1,3 @@
-import { EvidenceSummary } from "@/components/evidence-summary";
-import { MonitoringLoop } from "@/components/monitoring-loop";
-import { SearchAnalysisPanel } from "@/components/search-analysis-panel";
-import { SiteAudit } from "@/components/site-audit";
-import { useLocale, useT } from "@/lib/i18n";
-/**
- * The page renders a structured opportunities report. We assemble a deterministic
- * digest of the brand's tracked citation data (per-query standing vs the leading
- * competitor over 7d + 30d, citation difficulty, where answers are sourced, and
- * per-platform visibility) and make a single structured LLM completion (no web
- * search) to turn it into categorized opportunities. The report is cached
- * server-side and regenerated only when stale — see server/opportunities.ts.
- */
-
import { IconClock } from "@tabler/icons-react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
@@ -19,10 +5,16 @@ import { Skeleton } from "@workspace/ui/components/skeleton";
import { Spinner } from "@workspace/ui/components/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { useState } from "react";
+import { EvidenceSummary } from "@/components/evidence-summary";
+import { MonitoringLoop } from "@/components/monitoring-loop";
import { OpportunitiesReport } from "@/components/opportunities-report";
import { PageHeader } from "@/components/page-header";
+import { SearchAnalysisPanel } from "@/components/search-analysis-panel";
+import { SiteAudit } from "@/components/site-audit";
import { useBrandId } from "@/hooks/use-brand-id";
import { useOpportunities } from "@/hooks/use-opportunities";
+import { usePublicHash } from "@/hooks/use-public-hash";
+import { useLocale, useT } from "@/lib/i18n";
import { pageHead } from "@/lib/route-head";
export const Route = createFileRoute("/_authed/app/org/$org/brand/$brand/opportunities")({
@@ -31,56 +23,57 @@ export const Route = createFileRoute("/_authed/app/org/$org/brand/$brand/opportu
component: OpportunitiesPage,
});
+function actionTabDestination(next: string, current: string) {
+ if (next !== "actions") return next;
+ if (["search", "seo-actions"].includes(current)) return "seo-actions";
+ return current === "all-actions" ? "all-actions" : "actions";
+}
+
function OpportunitiesPage() {
const t = useT();
const brandId = useBrandId();
- const [tab, setTab] = useState("recommendations");
- const { data, isLoading, error, refetch, isFetching } = useOpportunities(brandId);
+ const hash = usePublicHash();
+ const navigate = Route.useNavigate();
+ const [focusActionId, setFocusActionId] = useState();
+ const tab = ["seo-actions", "all-actions"].includes(hash)
+ ? "actions"
+ : ["search", "actions", "evidence", "website"].includes(hash)
+ ? hash
+ : "recommendations";
+ const actionKind =
+ ({ "seo-actions": "seo", "all-actions": "all" } as const)[hash as "seo-actions" | "all-actions"] ?? "geo";
+ const setTab = (next: string) => {
+ void navigate({ hash: actionTabDestination(next, hash) });
+ };
+ const reportQuery = useOpportunities(brandId);
+ const { data, isLoading, generate, isGenerating, generationError } = reportQuery;
const { canManageConnections } = Route.useRouteContext();
- const infoContent = "Recommendations based on your visibility and citation metrics. Refreshed weekly.";
-
- let content: React.ReactNode;
- if (isLoading) {
- content = ;
- } else if (error) {
- content = (
-
- {t("Couldn't generate recommendations right now. Use Refresh recommendations to try again.")}
- {t("Your saved actions and collected answers are still available in the other tabs.")}
-
- );
- } else if (!data || data.reason === "insufficient-data" || !data.report) {
- content = (
-
- {t("Start with real answers")}
- {t("Collect your questions to find citation gaps and decide what to improve.")}
-
- setTab("actions")}>{t("Collect a question")}
- {canManageConnections && (
-
- {t("Check connections")}
-
- )}
-
-
- );
- } else {
- content = setTab("actions")} />;
- }
+ const infoContent =
+ "Saved recommendations are read without model calls. Generate a new report only when you choose to spend API credits.";
return (
setTab(String(value))} className="beat-section-tabs gap-6">
- {t("Recommendations")}
+ {t("GEO recommendations")}
{t("Search diagnosis")}
{t("Actions & retests")}
{t("Observed answers")}
@@ -91,18 +84,41 @@ function OpportunitiesPage() {
{t("AI recommendations use your research provider and may use API credits.")}
- void refetch()}>
- {t(isFetching ? "Analyzing…" : "Refresh recommendations")}
+ void generate()}>
+ {t(isGenerating ? "Analyzing…" : "Generate recommendations · uses API credits")}
+ {t("Could not generate recommendations. Check your research connection and retry explicitly.")}
+