diff --git a/.changeset/ai-platform-reports.md b/.changeset/ai-platform-reports.md new file mode 100644 index 0000000..95ee5b8 --- /dev/null +++ b/.changeset/ai-platform-reports.md @@ -0,0 +1,6 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +--- + +Add separate Bing and Google AI report imports with comparable history and guide users through their first monitoring run. diff --git a/.changeset/beatapi-monitoring-platforms.md b/.changeset/beatapi-monitoring-platforms.md new file mode 100644 index 0000000..ffc84b2 --- /dev/null +++ b/.changeset/beatapi-monitoring-platforms.md @@ -0,0 +1,8 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +"@workspace/config": patch +"@workspace/worker": patch +--- + +Track platforms only through BeatBrowser or BeatAPI with a default model per platform, remove product analytics, and reorder the sidebar into SEO, GEO and Optimize. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index e86c868..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -# Sponsorship is not configured for BeatGEO. diff --git a/.github/contributors.txt b/.github/contributors.txt deleted file mode 100644 index 274873b..0000000 --- a/.github/contributors.txt +++ /dev/null @@ -1 +0,0 @@ -# BeatGEO does not require the upstream CLA. Contributor history is preserved in Git. diff --git a/AGENTS.md b/AGENTS.md index cdd1f09..187adb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Before handing work back or opening a PR, run `pnpm lint` and get it passing; CI ## Environment -`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for the local workspace: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL`, `DISABLE_TELEMETRY=1`, and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require a research provider: `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`, or one direct LLM key (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `MISTRAL_API_KEY`) for onboarding and the Opportunities report. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all. +`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for the local workspace: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL` and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`. Monitoring reaches platforms only through BeatBrowser (`beat-browser`) or BeatAPI (`beatapi`); see `docs/providers.md`. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all. ## Git workflow diff --git a/CLA.md b/CLA.md deleted file mode 100644 index 9b39951..0000000 --- a/CLA.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributions - -BeatGEO uses the MIT license. See CONTRIBUTING.md and THIRD_PARTY_NOTICES.md. No separate upstream contributor agreement is required. diff --git a/apps/web/package.json b/apps/web/package.json index 2bb14f3..ad02583 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,7 +49,6 @@ "lucide-react": "^1.51.0", "pg": "^8.23.1", "pg-boss": "^12.36.0", - "posthog-js": "^1.435.8", "react": "^19.3.0", "react-dom": "^19.3.0", "react-icons": "^5.7.0", diff --git a/apps/web/src/components/__tests__/response-markdown.test.tsx b/apps/web/src/components/__tests__/response-markdown.test.tsx index 2fc39dd..7540f6a 100644 --- a/apps/web/src/components/__tests__/response-markdown.test.tsx +++ b/apps/web/src/components/__tests__/response-markdown.test.tsx @@ -1,12 +1,7 @@ /** - * Every provider stores its answers in a different shape, and each one reaches - * the prompt detail page through `extractTextContent` before it is rendered. - * A provider whose extraction hands back flattened text or glued-together - * blocks looks identical to a broken renderer from the page's side, so the two - * halves are checked together here rather than in isolation. - * - * The shapes mirror the fixtures in each provider's own tests in - * `packages/lib/src/providers/registry`. + * Stored answers reach the prompt detail page through `extractTextContent` + * before they are rendered, so extraction and rendering are checked together: + * flattened text looks identical to a broken renderer from the page's side. */ import { extractTextContent } from "@workspace/lib/text-extraction"; import { renderToStaticMarkup } from "react-dom/server"; @@ -14,79 +9,22 @@ import { describe, expect, it } from "vitest"; import { ResponseMarkdown } from "@/components/response-markdown"; const PROSE = "Here are the **top** trackers:"; -const TABLE = "| Tool | Price |\n| --- | --- |\n| Profound | $99 |"; +const TABLE = "| Tool | Price |\n| --- | --- |\n| Example Tracker | $99 |"; const ANSWER = `${PROSE}\n\n${TABLE}`; const render = (rawOutput: unknown, provider: string) => renderToStaticMarkup({extractTextContent(rawOutput, provider)}); -/** One answer, one shape per provider surface. */ const SURFACES: [name: string, provider: string, rawOutput: unknown][] = [ - ["openai-api", "openai-api", { output: [{ type: "message", content: [{ type: "output_text", text: ANSWER }] }] }], - ["anthropic-api", "anthropic-api", { content: [{ type: "text", text: ANSWER }] }], - ["mistral-api", "mistral-api", { outputs: [{ content: [{ type: "text", text: ANSWER }] }] }], - ["openrouter", "openrouter", { choices: [{ message: { content: ANSWER } }] }], - ["olostep", "olostep", { json_content: JSON.stringify({ result: { markdown_content: ANSWER } }) }], - ["brightdata chatbot", "brightdata", [{ answer_text_markdown: ANSWER, answer_text: "flattened" }]], - ["brightdata AI Overview", "brightdata", [{ ai_overview: { markdown: ANSWER } }]], - ["oxylabs ChatGPT", "oxylabs", { results: [{ content: { markdown_text: ANSWER } }] }], - ["oxylabs Perplexity", "oxylabs", { results: [{ content: { answer_results_md: ANSWER } }] }], - ["cloro chatbot", "cloro", { markdown: ANSWER, text: "flattened" }], - ["cloro AI Overview", "cloro", { aioverview: { markdown: ANSWER, text: "flattened" } }], - ["dataforseo scraper", "dataforseo", { tasks: [{ result: [{ markdown: ANSWER, sources: [] }] }] }], - [ - "dataforseo LLM Responses", - "dataforseo", - { tasks: [{ result: [{ items: [{ type: "message", sections: [{ type: "text", text: ANSWER }] }] }] }] }, - ], - [ - "dataforseo AI Overview", - "dataforseo", - { tasks: [{ result: [{ items: [{ type: "ai_overview", markdown: ANSWER }] }] }] }, - ], - // Rows written before the provider column existed dispatch on the model. - [ - "legacy row without a provider", - "chatgpt", - { output: [{ type: "message", content: [{ type: "output_text", text: ANSWER }] }] }, - ], + ["BeatBrowser observation", "beat-browser", { status: "completed", answer: ANSWER, citations: [] }], + ["BeatAPI answer", "beatapi", { choices: [{ finish_reason: "stop", message: { content: ANSWER } }] }], ]; describe("answer rendering across providers", () => { - it.each(SURFACES)("renders %s answers as markdown", (_name, provider, rawOutput) => { + it.each(SURFACES)("renders %s as markdown", (_name, provider, rawOutput) => { const html = render(rawOutput, provider); expect(html).toContain(""); expect(html).toContain("top"); - expect(html).not.toContain("flattened"); - }); - - /** - * These three surfaces arrive as a list of blocks rather than one string. - * Joined with a single newline they render as one run-on paragraph, which - * is what the extraction's blank-line join exists to prevent. - */ - it.each([ - [ - "brightdata AI Overview", - "brightdata", - [{ ai_overview: { texts: [{ snippet: "First." }, { snippet: "Second." }] } }], - ], - [ - "dataforseo scraper items", - "dataforseo", - { tasks: [{ result: [{ sources: [], items: [{ markdown: "First." }, { markdown: "Second." }] }] }] }, - ], - [ - "oxylabs AI Overview", - "oxylabs", - { - results: [ - { content: { ai_overviews: [{ answer_text: [{ fragments: [{ text: "First." }, { text: "Second." }] }] }] } }, - ], - }, - ], - ])("keeps %s blocks as separate paragraphs", (_name, provider, rawOutput) => { - expect(render(rawOutput, provider).match(/

/g)).toHaveLength(2); }); it("does not load relative or protocol-relative images", () => { diff --git a/apps/web/src/components/ai-platform-reports.tsx b/apps/web/src/components/ai-platform-reports.tsx new file mode 100644 index 0000000..ded5f06 --- /dev/null +++ b/apps/web/src/components/ai-platform-reports.tsx @@ -0,0 +1,402 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type AiReport, + type AiReportDimension, + type AiReportInput, + type AiReportSource, + compareAiReports, + parseAiReport, +} from "@workspace/lib/ai-reports"; +import { Button } from "@workspace/ui/components/button"; +import { Input } from "@workspace/ui/components/input"; +import { useMemo, useState } from "react"; +import { useBrandId } from "@/hooks/use-brand-id"; +import { useT } from "@/lib/i18n"; +import { deleteAiReportFn, importAiReportFn, listAiReportsFn } from "@/server/ai-reports"; + +const fieldClass = "h-10 w-full min-w-0 rounded-md border bg-background px-3 text-sm"; +export function AiPlatformReports() { + const brandId = useBrandId(); + return ; +} +function ReportWorkspace({ brandId }: { brandId: string }) { + const t = useT(), + client = useQueryClient(); + const queryKey = ["ai-platform-reports", brandId]; + const query = useQuery({ queryKey, queryFn: () => listAiReportsFn({ data: { brandId } }) }); + const [input, setInput] = useState({ + source: "bing", + dimension: "page", + site: "", + startDate: "", + endDate: "", + scope: "", + content: "", + }); + const [confirmed, setConfirmed] = useState(false), + [busy, setBusy] = useState(false), + [message, setMessage] = useState(""); + const [selected, setSelected] = useState(""), + [previous, setPrevious] = useState(""), + [filter, setFilter] = useState(""), + [offset, setOffset] = useState(0); + const reports = query.data ?? []; + const { report, comparison, rows } = reportView(reports, selected, previous, filter); + const preview = useMemo(() => { + if (!input.content) return { data: null, error: "" }; + try { + return { data: parseAiReport(input), error: "" }; + } catch (error) { + return { data: null, error: error instanceof Error ? error.message : "Invalid report." }; + } + }, [input]); + function update(patch: Partial) { + setInput((v) => ({ ...v, ...patch })); + setConfirmed(false); + setMessage(""); + } + async function save() { + if (!preview.data || !confirmed || busy) return; + setBusy(true); + setMessage(""); + try { + const result = await importAiReportFn({ data: { ...input, brandId } }); + await client.invalidateQueries({ queryKey }); + setSelected(result.id); + setPrevious(""); + setOffset(0); + setMessage(result.duplicate ? "This report is already saved; no counts were added." : "AI report saved."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not save AI report."); + } finally { + setBusy(false); + } + } + return ( +

+
+

{t("AI platform reports")}

+

+ {t( + "Import platform observations separately from your monitored questions. Imports do not enable automatic synchronization.", + )} +

+
+
+ {(["bing", "google"] as const).map((source) => { + const latest = reports.find((r) => r.source === source); + return ( +
+

+ {source === "bing" ? "Bing AI Performance" : "Google Search Console · AI"} +

+

+ {t( + source === "bing" + ? "Citations · Microsoft and supported partners" + : "Impressions · Google AI Overviews and AI Mode", + )} +

+

+ {query.isPending + ? t("Loading…") + : query.isError + ? t("Could not load AI reports.") + : latest + ? `${latest.startDate} – ${latest.endDate} · ${latest.site}` + : t("No AI report imported")} +

+ + {t("Open official dashboard")} + +
+ ); + })} +
+ {query.isError && ( +

+ {t("Could not load AI reports.")}{" "} + +

+ )} +
+ {t("Import AI report CSV")} +

+ {t( + "Export one table from the AI report, then provide its website, dates and filters. Supported: page, daily history, and Bing grounding queries. Use exact counts, not Topic Portfolio opportunity ranges. CSV only; extract ZIP exports first. Maximum 2 MB / 2,000 rows.", + )} +

+
+ + + + + + + +
+ {preview.error && ( +

+ {t(preview.error)} +

+ )} + {preview.data && ( +
+

+ {t("Import preview")} · {preview.data.rows.length} {t("rows")} ·{" "} + {t(input.source === "bing" ? "Citations" : "Impressions")} +

+
+
+ + {preview.data.rows.slice(0, 5).map((r) => ( + + + + + ))} + +
{r.key}{r.value ?? "—"}
+ + + + )} + + + {message && ( +

+ {t(message)} +

+ )} + {report && ( +
+

{t("Saved AI observations")}

+
+ + +
+

+ {report.site} · {report.startDate} – {report.endDate} · {report.scope} +
+ {t("Imported at")}: {new Date(report.importedAt).toLocaleString()} +

+

+ {t( + "Compare only equal-length, non-overlapping periods from the same source, dimension, website and filters. Missing rows are unknown, not zero. Daily reports remain separate observations. Row sums are not site totals; different platforms are never added together.", + )} +

+ { + setFilter(e.target.value); + setOffset(0); + }} + /> +
+ + + + + + {comparison && ( + <> + + + + )} + + + + {rows.slice(offset, offset + 50).map((row) => ( + + + + {comparison && ( + <> + + + + )} + + ))} + +
{t(report.dimension)}{t(report.source === "bing" ? "Citations" : "Impressions")}{t("Previous period")}{t("Change")}
+ {report.dimension === "page" ? ( + + {row.key} + + ) : ( + row.key + )} + {row.value?.toLocaleString() ?? "—"}{"previous" in row ? (row.previous?.toLocaleString() ?? "—") : "—"}{"delta" in row ? (row.delta?.toLocaleString() ?? "—") : "—"}
+
+ {!rows.length &&

{t("No matching observations.")}

} +
+ + + {rows.length ? offset + 1 : 0}–{Math.min(offset + 50, rows.length)} / {rows.length} + + + +
+
+ )} + + ); +} + +function reportView(reports: AiReport[], selected: string, previous: string, filter: string) { + const report = reports.find((r) => r.id === selected) ?? reports[0]; + const baseline = reports.find((r) => r.id === previous); + const comparison = report && baseline ? compareAiReports(report, baseline) : null; + const rows = (comparison ?? report?.rows ?? []).filter((r) => r.key.toLowerCase().includes(filter.toLowerCase())); + return { report, comparison, rows }; +} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index e9b2182..01c03b0 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -43,13 +43,45 @@ export type AppSidebarProps = ShellScope; type SidebarBrand = Pick; +/** + * SEO comes first because search is the foundation AI answers draw on; GEO + * carries the most entries because monitoring AI answers is the product's core; + * Optimize closes the loop with actions and matched retests. + */ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seoEnabled: boolean): NavGroup[] { const params = brandLinkParams(organization, brand); - const dashboard: NavItem[] = [ - { title: "Overview", link: { to: "/app/org/$org/brand/$brand", params }, icon: IconDashboard, exact: true }, + const seoPage = (view: "home" | "actions") => SEO_PAGES.find((page) => page.view === view)!; + + const seo: NavItem[] = [ + { + title: "Search performance", + link: { to: "/app/org/$org/brand/$brand/search-console", params }, + icon: IconChartBar, + }, + { + title: "Search diagnosis", + link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "search" }, + icon: IconSearch, + }, ]; + if (seoEnabled) + seo.push( + { + title: seoPage("home").title, + link: { to: "/app/org/$org/brand/$brand/seo", params, hash: "" }, + icon: IconWorld, + exact: true, + }, + { + title: seoPage("actions").title, + link: { to: "/app/org/$org/brand/$brand/seo", params, hash: "actions" }, + icon: IconFileText, + exact: true, + }, + ); - dashboard.push( + const geo: NavItem[] = [ + { title: "Overview", link: { to: "/app/org/$org/brand/$brand", params }, icon: IconDashboard, exact: true }, { title: "Questions", link: { to: "/app/org/$org/brand/$brand/settings/prompts", params }, @@ -61,81 +93,37 @@ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seo matchLinks: INSIGHT_PAGES.slice(1).map((page) => ({ to: page.to, params })), icon: IconChartBar, }, + { title: "Traffic analysis", link: { to: "/app/org/$org/brand/$brand/traffic", params }, icon: IconLink }, + { + title: "Competitors", + link: { to: "/app/org/$org/brand/$brand/settings/competitors", params }, + icon: IconBuildings, + }, + { + title: "Monitoring platforms", + link: { to: "/app/org/$org/brand/$brand/settings/llms", params }, + icon: IconCpu, + }, + ]; + + const optimize: NavItem[] = [ { title: "GEO recommendations", link: { to: "/app/org/$org/brand/$brand/opportunities", params }, icon: IconTarget, }, { - title: "GEO actions", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "actions" }, - icon: IconFileText, + title: "Actions & retests", + link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "all-actions" }, + icon: IconHistory, }, - ); - - const groups: NavGroup[] = []; - if (seoEnabled) - groups.push({ - label: "SEO", - items: [ - { - title: "Search diagnosis", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "search" }, - icon: IconTarget, - }, - { - title: "SEO actions", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "seo-actions" }, - icon: IconFileText, - }, - ...SEO_PAGES.map( - (page): NavItem => ({ - title: page.title, - link: { to: "/app/org/$org/brand/$brand/seo", params, hash: page.view === "home" ? "" : page.view }, - icon: { - home: IconDashboard, - domain: IconWorld, - keywords: IconSearch, - backlinks: IconLink, - results: IconHistory, - actions: IconFileText, - }[page.view], - exact: true, - }), - ), - { - title: "Search performance", - link: { to: "/app/org/$org/brand/$brand/search-console", params }, - icon: IconChartBar, - }, - ], - }); - groups.push({ label: "GEO", items: dashboard }); - groups.push({ - label: "Analytics", - items: [ - { title: "Traffic analysis", link: { to: "/app/org/$org/brand/$brand/traffic", params }, icon: IconChartBar }, - { - title: "All improvement actions", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "all-actions" }, - icon: IconFileText, - }, - ], - }); - - groups.push({ - label: "Settings", - items: [ - { - title: "Competitors", - link: { to: "/app/org/$org/brand/$brand/settings/competitors", params }, - icon: IconBuildings, - }, - { title: "Models", link: { to: "/app/org/$org/brand/$brand/settings/llms", params }, icon: IconCpu }, - ], - }); + ]; - return groups; + return [ + { label: "SEO", items: seo }, + { label: "GEO", items: geo }, + { label: "Optimize", items: optimize }, + ]; } /** Developer and learning pages, shared by every page so the rail never changes shape. */ diff --git a/apps/web/src/components/google-connection.tsx b/apps/web/src/components/google-connection.tsx index e7c5e65..b255059 100644 --- a/apps/web/src/components/google-connection.tsx +++ b/apps/web/src/components/google-connection.tsx @@ -42,9 +42,14 @@ export function GoogleConnection({ )}

)} +

+ {t( + "Saved authorization does not guarantee the next refresh will succeed. Connection errors do not erase the saved report shown below.", + )} +

  1. - {t("1. Authorize Google")} · {t(connected ? "Connected" : "Not connected")} + {t("1. Authorize Google")} · {t(connected ? "Authorization saved" : "Not connected")}
  2. {t("2. Choose Search Console property")} · {t(data.gsc?.config?.property ? "Selected" : "Not selected")} diff --git a/apps/web/src/components/gsc-panel.tsx b/apps/web/src/components/gsc-panel.tsx index 8a6ac1e..57b32d2 100644 --- a/apps/web/src/components/gsc-panel.tsx +++ b/apps/web/src/components/gsc-panel.tsx @@ -94,6 +94,10 @@ export function GscPanel({

    {result.startDate} – {result.endDate} · {t("Google Search, web results")} +
    + {t("Last successful data fetch")}: {new Date(result.fetchedAt).toLocaleString()} +
    + {t("Saved search data is not a live connection check or a separate AI report.")}

    {result.totals && (
    diff --git a/apps/web/src/components/monitoring-start.tsx b/apps/web/src/components/monitoring-start.tsx new file mode 100644 index 0000000..709e356 --- /dev/null +++ b/apps/web/src/components/monitoring-start.tsx @@ -0,0 +1,45 @@ +import { Link } from "@tanstack/react-router"; +import { useBrandParams } from "@/hooks/use-route-params"; +import { useT } from "@/lib/i18n"; +export function MonitoringStart() { + const t = useT(), + params = useBrandParams(); + return ( +
    +

    {t("Your AI monitoring workflow")}

    +
    + {( + [ + ["1. Check connections", "/app/org/$org/brand/$brand/settings/brand"], + ["2. Choose monitoring platforms", "/app/org/$org/brand/$brand/settings/llms"], + ["3. Review monitoring questions", "/app/org/$org/brand/$brand/settings/prompts"], + ["4. Collect and review answers", "/app/org/$org/brand/$brand/opportunities"], + ] as const + ).map(([label, to]) => ( + + {t(label)} + + ))} +
    +

    + {t( + "Question monitoring measures completed answer samples. Platform reports record site impressions or citations. SEO research and referral visits are separate evidence.", + )} +

    + + {t("Import Bing or Google AI reports")} + +
    + ); +} diff --git a/apps/web/src/components/prompt-suggestions.tsx b/apps/web/src/components/prompt-suggestions.tsx index ef326cd..c800852 100644 --- a/apps/web/src/components/prompt-suggestions.tsx +++ b/apps/web/src/components/prompt-suggestions.tsx @@ -7,7 +7,6 @@ import { type Ref, type RefObject, useEffect, useRef, useState } from "react"; import { type EditablePrompt, newPromptEntry } from "@/components/prompts-list-editor"; import { usePolledBrandJob } from "@/hooks/use-polled-brand-job"; import { useT } from "@/lib/i18n"; -import { trackEvent } from "@/lib/posthog"; import { cancelPromptSuggestionsFn, getPromptSuggestionsStatusFn, @@ -76,7 +75,6 @@ export function usePromptSuggestions({ start: () => { setError(null); setNothingNew(false); - trackEvent("prompt_suggestions_requested"); job.run(); }, cancel: job.cancel, diff --git a/apps/web/src/components/prompt-wizard.tsx b/apps/web/src/components/prompt-wizard.tsx index 0bd8d25..894e99a 100644 --- a/apps/web/src/components/prompt-wizard.tsx +++ b/apps/web/src/components/prompt-wizard.tsx @@ -25,7 +25,6 @@ import { dashboardKeys } from "@/hooks/use-dashboard-summary"; import { usePolledBrandJob } from "@/hooks/use-polled-brand-job"; import { promptsSummaryKeys } from "@/hooks/use-prompts-summary"; import { useBrandParams } from "@/hooks/use-route-params"; -import { trackEvent } from "@/lib/posthog"; import { useWriteErrorMessage } from "@/lib/write-errors"; import { cancelAnalyzeBrandFn, @@ -159,10 +158,6 @@ export default function PromptWizard() { ), }); setReviewing(true); - trackEvent("onboarding_analyzed", { - competitor_count: suggestion.competitors.length, - prompt_count: suggestion.suggestedPrompts.length, - }); }, onError: setError, startErrorFallback: "Analysis failed", @@ -222,12 +217,6 @@ export default function PromptWizard() { }, }); - trackEvent("wizard_completed", { - prompts_created: promptsPayload.length, - competitors_created: competitorsPayload.length, - skipped: false, - }); - // Deployments without an onboardingRedirectUrlTemplate (e.g. local mode) skip the full reload, so caches fetched while !onboarded must be busted explicitly. queryClient.invalidateQueries({ queryKey: brandKeys.all }); queryClient.invalidateQueries({ queryKey: dashboardKeys.all }); diff --git a/apps/web/src/components/prompts-editor.tsx b/apps/web/src/components/prompts-editor.tsx index 6c1ac2b..b5ea274 100644 --- a/apps/web/src/components/prompts-editor.tsx +++ b/apps/web/src/components/prompts-editor.tsx @@ -12,7 +12,6 @@ import { type EditablePrompt, PromptsListEditor } from "@/components/prompts-lis import { UnsavedChangesBar } from "@/components/unsaved-changes-bar"; import { useInvalidatePromptsSummary } from "@/hooks/use-prompts-summary"; import { useT } from "@/lib/i18n"; -import { trackEvent } from "@/lib/posthog"; import { useWriteErrorMessage } from "@/lib/write-errors"; import { updatePromptsFn } from "@/server/prompts"; @@ -144,13 +143,6 @@ export function PromptsEditor({ initialPrompts, brandId, pageTitle, pageDescript const saved = await updatePromptsFn({ data: { brandId, prompts: allPrompts } }); - trackEvent("prompts_updated", { - added: addedCount, - edited: editedCount, - deleted: removedCount, - suggested: validPrompts.filter((p) => p.suggested && !p.id).length, - }); - // Adopt the server's rows so newly inserted prompts carry their real // ids — re-saving without them would insert duplicates. const next = toEditablePrompts(saved); diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts index cdd53a3..559e9b4 100644 --- a/apps/web/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -3,17 +3,6 @@ // Every var here must have an entry in packages/config/src/env-registry.ts // (enforced by env-registry.test.ts). -interface ImportMetaEnv { - // Analytics - readonly VITE_PLAUSIBLE_DOMAIN?: string; - readonly VITE_CLARITY_PROJECT_ID?: string; - readonly VITE_POSTHOG_KEY?: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} - // Server-side environment variables (accessed via process.env in server functions) // and Vite define globals declare global { @@ -33,31 +22,17 @@ declare global { readonly BEAT_BROWSER_ARGS_JSON?: string; readonly BROWSER_RUNNER_TOKEN?: string; - readonly OPENAI_API_KEY: string; - readonly ANTHROPIC_API_KEY: string; - readonly MISTRAL_API_KEY?: string; - readonly OPENROUTER_API_KEY?: string; - readonly SEARCHAPI_API_KEY?: string; - readonly OLOSTEP_API_KEY?: string; - readonly BRIGHTDATA_API_TOKEN?: string; - readonly OXYLABS_USERNAME?: string; - readonly OXYLABS_PASSWORD?: string; - readonly CLORO_API_KEY?: string; readonly JINA_API_KEY?: string; readonly BEATAPI_API_KEY?: string; readonly BEATAPI_RESEARCH_MODEL?: string; readonly ONBOARDING_LLM_TARGET?: string; readonly BEAT_GEO_ENCRYPTION_KEY?: string; - readonly BEAT_GEO_POSTHOG_KEY?: string; readonly BEAT_GEO_ENCRYPTION_KEY_OLD?: string; - readonly DATAFORSEO_LOGIN: string; - readonly DATAFORSEO_PASSWORD: string; readonly BETTER_AUTH_SECRET?: string; readonly ADMIN_API_KEYS?: string; readonly DEFAULT_BRAND_DOMAINS?: string; readonly ENVIRONMENT?: string; - readonly DISABLE_TELEMETRY?: string; } } } diff --git a/apps/web/src/lib/__tests__/fanout-analysis.test.ts b/apps/web/src/lib/__tests__/fanout-analysis.test.ts index 62b716a..ab91687 100644 --- a/apps/web/src/lib/__tests__/fanout-analysis.test.ts +++ b/apps/web/src/lib/__tests__/fanout-analysis.test.ts @@ -161,14 +161,14 @@ describe("computeFanoutAnalysis: 'unavailable' sentinel", () => { // store ["unavailable"] in web_queries when a search happened but the strings // aren't exposed. It's not a real fan-out query and must never reach the page. const breakdown: FanoutBreakdownRow[] = [ - { prompt_id: "p1", model: "openrouter-claude", query: UNAVAILABLE_SENTINEL, count: 6, brand_mentions: 2 }, - { prompt_id: "p1", model: "openrouter-gpt", query: "Unavailable", count: 3, brand_mentions: 1 }, // mixed case + { prompt_id: "p1", model: "api-claude", query: UNAVAILABLE_SENTINEL, count: 6, brand_mentions: 2 }, + { prompt_id: "p1", model: "api-gpt", query: "Unavailable", count: 3, brand_mentions: 1 }, // mixed case { prompt_id: "p1", model: "chatgpt", query: "best crm software 2026", count: 4, brand_mentions: 3 }, ]; // The SQL filters the sentinel, so OpenRouter's model totals come back at 0. const modelTotals: FanoutModelTotalRow[] = [ - { model: "openrouter-claude", runs: 8, fanout_runs: 0, total_queries: 0 }, - { model: "openrouter-gpt", runs: 5, fanout_runs: 0, total_queries: 0 }, + { model: "api-claude", runs: 8, fanout_runs: 0, total_queries: 0 }, + { model: "api-gpt", runs: 5, fanout_runs: 0, total_queries: 0 }, { model: "chatgpt", runs: 4, fanout_runs: 4, total_queries: 4 }, ]; const a = computeFanoutAnalysis(breakdown, modelTotals, promptMap); @@ -184,8 +184,8 @@ describe("computeFanoutAnalysis: 'unavailable' sentinel", () => { it("surfaces sentinel-only providers in the per-model breakdown with zero fan-out", () => { const byModel = Object.fromEntries(a.byModel.map((m) => [m.model, m])); - expect(byModel["openrouter-claude"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); - expect(byModel["openrouter-gpt"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); + expect(byModel["api-claude"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); + expect(byModel["api-gpt"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); expect(byModel.chatgpt.totalQueries).toBe(4); }); }); diff --git a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts index d019a28..a3db398 100644 --- a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts +++ b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts @@ -243,12 +243,12 @@ describe("model filter", () => { const grounded = await createRun(brandId, promptId, { at, brandMentioned: true, - provider: "openai-api", + provider: "beatapi", webSearch: true, }); - const scraped = await createRun(brandId, promptId, { at, brandMentioned: false, provider: "brightdata" }); - await createRun(brandId, promptId, { at, brandMentioned: false, provider: "openai-api", webSearch: false }); - await createRun(brandId, promptId, { at, brandMentioned: true, model: "claude", provider: "anthropic-api" }); + const scraped = await createRun(brandId, promptId, { at, brandMentioned: false, provider: "beat-browser" }); + await createRun(brandId, promptId, { at, brandMentioned: false, provider: "beatapi", webSearch: false }); + await createRun(brandId, promptId, { at, brandMentioned: true, model: "claude", provider: "beatapi" }); await createCitation(grounded, "https://a.example/1"); await createCitation(scraped, "https://b.example/1"); await createCitation(scraped, "https://b.example/2"); diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts index 3c7c3b9..ae02313 100644 --- a/apps/web/src/lib/breadcrumbs.ts +++ b/apps/web/src/lib/breadcrumbs.ts @@ -74,18 +74,17 @@ function pageCrumbs(label: string | undefined, pathname: string, hash: string): 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 === "all-actions" ? "Optimize" : "SEO", + href: hash === "all-actions" ? pathname : pathname.replace(/opportunities$/, "seo"), }, { - label: - hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "All improvement actions", + label: hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "Actions & retests", href: `${pathname}#${hash}`, }, ]; if (label === "Traffic analysis") return [ - { label: "Analytics", href: pathname }, + { label: "GEO", href: pathname.replace(/traffic$/, "") }, { label, href: pathname }, ]; if (label === "Search performance") diff --git a/apps/web/src/lib/clarity.ts b/apps/web/src/lib/clarity.ts deleted file mode 100644 index 0afb7cc..0000000 --- a/apps/web/src/lib/clarity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { onAnalyticsConsent } from "@workspace/ui/lib/cookie-consent"; - -type ClarityApi = ((command: string, ...args: unknown[]) => void) & { q?: unknown[][] }; - -declare global { - interface Window { - clarity?: ClarityApi; - } -} - -let injected = false; - -function load(projectId: string): void { - if (injected) return; - injected = true; - // Clarity replays this queue once its tag loads, so a choice made while the - // script is still in flight isn't lost. - window.clarity ??= Object.assign( - (...args: unknown[]) => { - window.clarity?.q?.push(args); - }, - { q: [] as unknown[][] }, - ); - const script = document.createElement("script"); - script.async = true; - script.src = `https://www.clarity.ms/tag/${projectId}`; - document.head.appendChild(script); -} - -/** - * Load Microsoft Clarity only while analytics consent is in effect. - * - * Clarity records session replays, so the tag is never fetched before the - * visitor has answered — which is the whole reason it isn't a `