feat: AI Visibility — Brand Lookup and Prompt Explorer (#128)

This commit is contained in:
Ben Senescu 2026-04-23 10:48:38 -04:00 committed by GitHub
parent da2509ec74
commit faf7a294a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 6289 additions and 489 deletions

View File

@ -28,6 +28,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:ci": "vitest run --reporter=dot", "test:ci": "vitest run --reporter=dot",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts", "billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts", "seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware" "ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
}, },
@ -71,7 +72,9 @@
"posthog-node": "^5.28.5", "posthog-node": "^5.28.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"remeda": "^2.33.6", "remeda": "^2.33.6",
"robots-parser": "^3.0.1", "robots-parser": "^3.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",

891
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,191 @@
import process from "node:process";
import {
buildLlmTarget,
CHATGPT_LANGUAGE_CODE,
CHATGPT_LOCATION_CODE,
fetchLlmAggregatedMetricsRaw,
fetchLlmMentionsSearchRaw,
fetchLlmTopPagesRaw,
type LlmPlatform,
} from "@/server/lib/dataforseoLlm";
import { applyBillingMarkupUsd } from "@/shared/billing";
import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv();
const args = parseArgs(process.argv.slice(2));
await main();
/**
* Confirm what a single Brand Lookup actually costs against DataForSEO.
* Mirrors `backlinks-cost-profile.ts` but reports per-call USD cost so we can
* verify the on-screen "Est. $X" against reality.
*/
async function main() {
if (process.env.CI === "true" && args.allowCi !== "true") {
printUsageAndExit(
"Refusing to run live billing checks in CI without --allowCi=true.",
);
}
if (args.confirmLive !== "true") {
printUsageAndExit(
"This command makes live, billable DataForSEO requests. Re-run with --confirmLive=true.",
);
}
if (!process.env.DATAFORSEO_API_KEY) {
printUsageAndExit("Missing DATAFORSEO_API_KEY.");
}
const target = args.target;
if (!target) {
printUsageAndExit("Missing --target.");
}
const targetType = parseTargetType(args.targetType);
const userLocationCode = parsePositiveInteger(args.locationCode, 2840);
const userLanguageCode = args.languageCode ?? "en";
const repeat = parsePositiveInteger(args.repeat, 1);
const llmTarget = buildLlmTarget({ type: targetType, value: target });
const platforms: LlmPlatform[] = ["chat_gpt", "google"];
const allRuns: RunSummary[] = [];
for (let runIndex = 0; runIndex < repeat; runIndex += 1) {
const calls: CallRecord[] = [];
for (const platform of platforms) {
// ChatGPT data is only indexed for US/en, mirroring the production
// brandLookup service.
const locationCode =
platform === "chat_gpt" ? CHATGPT_LOCATION_CODE : userLocationCode;
const languageCode =
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : userLanguageCode;
const aggregated = await fetchLlmAggregatedMetricsRaw({
target: llmTarget,
platform,
locationCode,
languageCode,
internalListLimit: 20,
});
calls.push(toRecord(platform, "aggregated_metrics", aggregated.billing));
const topPages = await fetchLlmTopPagesRaw({
target: llmTarget,
platform,
locationCode,
languageCode,
itemsListLimit: 10,
});
calls.push(toRecord(platform, "top_pages", topPages.billing));
const mentions = await fetchLlmMentionsSearchRaw({
target: llmTarget,
platform,
locationCode,
languageCode,
limit: 25,
});
calls.push(toRecord(platform, "mentions_search", mentions.billing));
}
const totalRawUsd = sum(calls.map((c) => c.rawUsd));
allRuns.push({
run: runIndex + 1,
calls,
totalRawUsd: round(totalRawUsd),
totalBilledUsd: applyBillingMarkupUsd(totalRawUsd),
});
}
const aggregateRawUsd = sum(allRuns.map((r) => r.totalRawUsd));
const aggregateBilledUsd = applyBillingMarkupUsd(aggregateRawUsd);
console.log(
JSON.stringify(
{
input: {
target,
targetType,
userLocationCode,
userLanguageCode,
repeat,
},
runs: allRuns,
aggregate: {
totalRawUsd: round(aggregateRawUsd),
totalBilledUsd: aggregateBilledUsd,
avgRawPerLookupUsd: round(aggregateRawUsd / allRuns.length),
avgBilledPerLookupUsd: round(aggregateBilledUsd / allRuns.length),
},
},
null,
2,
),
);
}
type CallRecord = {
platform: LlmPlatform;
endpoint: string;
path: string;
resultCount: number | null;
rawUsd: number;
billedUsd: number;
};
type RunSummary = {
run: number;
calls: CallRecord[];
totalRawUsd: number;
totalBilledUsd: number;
};
function toRecord(
platform: LlmPlatform,
endpoint: string,
billing: { costUsd: number; path: string[]; resultCount: number | null },
): CallRecord {
return {
platform,
endpoint,
path: billing.path.join("/"),
resultCount: billing.resultCount,
rawUsd: round(billing.costUsd),
billedUsd: applyBillingMarkupUsd(billing.costUsd),
};
}
function parseTargetType(value: string | undefined): "domain" | "keyword" {
if (!value || value === "domain") return "domain";
if (value === "keyword") return "keyword";
printUsageAndExit(
`Invalid --targetType: ${value}. Expected domain or keyword.`,
);
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function sum(values: number[]): number {
return values.reduce((total, value) => total + value, 0);
}
function round(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function printUsageAndExit(message: string): never {
console.error(message);
console.error(
"Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]",
);
process.exit(1);
}

View File

@ -0,0 +1,43 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
type SortableColumn = {
getIsSorted: () => false | "asc" | "desc";
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
};
export function SortableHeader({
column,
label,
helpText,
align,
}: {
column: SortableColumn;
label: string;
helpText?: string;
align?: "left" | "right";
}) {
const sorted = column.getIsSorted();
const content = (
<button
type="button"
className="inline-flex items-center gap-1 font-medium transition-colors hover:text-base-content"
onClick={column.getToggleSortingHandler()}
aria-label={`Sort by ${label}`}
aria-pressed={!!sorted}
>
{helpText ? <HeaderHelpLabel label={label} helpText={helpText} /> : label}
{sorted === "asc" ? (
<ArrowUp className="size-3 shrink-0" />
) : sorted === "desc" ? (
<ArrowDown className="size-3 shrink-0" />
) : null}
</button>
);
if (align === "right") {
return <span className="flex w-full justify-end">{content}</span>;
}
return content;
}

View File

@ -0,0 +1,76 @@
import type { Row } from "@tanstack/react-table";
/**
* Null/undefined-aware sorting functions that keep blank rows at the bottom
* regardless of direction. TanStack's built-in `sortUndefined: "last"` gets
* inverted by the desc sign flip these helpers read the column's sort
* direction from the cell context and return a value that survives the flip.
*/
export function isDescending<TData>(
row: Row<TData>,
columnId: string,
): boolean {
const cell = row.getAllCells().find((c) => c.column.id === columnId);
return cell?.column.getIsSorted() === "desc";
}
/**
* Compare two nullable numeric values with nulls always at the bottom,
* regardless of the column's current sort direction. Use this directly for
* tiebreakers or when the value isn't the column's accessor.
*/
export function compareNumericNullsLast(
a: number | null | undefined,
b: number | null | undefined,
descending: boolean,
): number {
if (a == null && b == null) return 0;
if (a == null || b == null) {
const sign = descending ? -1 : 1;
return (a == null ? 1 : -1) * sign;
}
return a - b;
}
export function numericNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
columnId: string,
): number {
return compareNumericNullsLast(
rowA.getValue<number | null | undefined>(columnId),
rowB.getValue<number | null | undefined>(columnId),
isDescending(rowA, columnId),
);
}
export function stringNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
columnId: string,
): number {
const a = rowA.getValue<string | null | undefined>(columnId);
const b = rowB.getValue<string | null | undefined>(columnId);
if (!a && !b) return 0;
if (!a || !b) {
const sign = isDescending(rowA, columnId) ? -1 : 1;
return (!a ? 1 : -1) * sign;
}
return a.toLowerCase().localeCompare(b.toLowerCase());
}
export function dateNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
columnId: string,
): number {
const a = rowA.getValue<string | null | undefined>(columnId);
const b = rowB.getValue<string | null | undefined>(columnId);
if (!a && !b) return 0;
if (!a || !b) {
const sign = isDescending(rowA, columnId) ? -1 : 1;
return (!a ? 1 : -1) * sign;
}
return Date.parse(a) - Date.parse(b);
}

View File

@ -0,0 +1,210 @@
import { useEffect, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import {
AlertCircle,
ArrowLeft,
BarChart3,
Quote,
TrendingUp,
} from "lucide-react";
import { lookupBrand } from "@/serverFunctions/ai-search";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults";
import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard";
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
type Props = {
projectId: string;
initialQuery: string;
onQueryChange: (next: string) => void;
};
const BRAND_LOOKUP_BULLETS = [
{
icon: TrendingUp,
title: "Track AI visibility",
body: "Count how often ChatGPT and Google AI Overview cite your brand, and watch the trend month over month.",
},
{
icon: Quote,
title: "See the prompts",
body: "View the actual user questions where LLMs reference your domain — the real demand driving AI traffic.",
},
{
icon: BarChart3,
title: "Map the competition",
body: "Spot the pages LLMs cite alongside you so you know who's competing for attention in AI answers.",
},
];
export function BrandLookupPage(props: Props) {
return (
<AutumnProvider>
<BrandLookupPageInner {...props} />
</AutumnProvider>
);
}
function BrandLookupPageInner({
projectId,
initialQuery,
onQueryChange,
}: Props) {
const [query, setQuery] = useState(initialQuery);
const [validationError, setValidationError] = useState<string | null>(null);
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const planKnown = customerQuery.isSuccess || customerQuery.isError;
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const trimmedInitialQuery = initialQuery.trim();
const hasActiveQuery = trimmedInitialQuery.length > 0;
const lookupQuery = useQuery({
queryKey: ["brand-lookup", projectId, trimmedInitialQuery],
queryFn: () =>
lookupBrand({
data: {
projectId,
query: trimmedInitialQuery,
locationCode: 2840,
languageCode: "en",
},
}),
enabled: hasActiveQuery && !isFreePlan,
staleTime: 5 * 60 * 1000,
retry: false,
});
const {
history,
isLoaded: historyLoaded,
addSearch,
removeHistoryItem,
} = useBrandLookupSearchHistory(projectId);
useEffect(() => {
if (hasActiveQuery && lookupQuery.isSuccess) {
addSearch({ query: trimmedInitialQuery });
}
}, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const trimmed = query.trim();
if (trimmed.length === 0) {
setValidationError("Enter a brand name or domain");
return;
}
if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) {
setValidationError(
`Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
);
return;
}
setValidationError(null);
onQueryChange(trimmed);
};
const handleSelectHistoryItem = (item: { query: string }) => {
setQuery(item.query);
setValidationError(null);
onQueryChange(item.query);
};
const handleShowRecentSearches = () => {
setQuery("");
setValidationError(null);
onQueryChange("");
};
const isLoading = hasActiveQuery && lookupQuery.isPending;
const errorMessage =
hasActiveQuery && lookupQuery.isError
? getStandardErrorMessage(lookupQuery.error)
: null;
const resultData = hasActiveQuery ? lookupQuery.data : undefined;
if (!planKnown) return null;
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Brand Lookup</h1>
<p className="text-sm text-base-content/70">
See how AI search cites any brand name or domain.
</p>
</div>
{isFreePlan ? (
<AiSearchPaidPlanGate
feature="Brand Lookup"
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, the prompts driving them, and the pages cited alongside yours."
bullets={BRAND_LOOKUP_BULLETS}
/>
) : (
<>
<BrandLookupSearchCard
query={query}
onQueryChange={(next) => {
setQuery(next);
if (validationError) setValidationError(null);
}}
onSubmit={handleSubmit}
isLoading={isLoading}
validationError={validationError}
/>
{errorMessage ? (
<div
role="alert"
className="flex items-start gap-2 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
) : null}
{isLoading ? (
<AiSearchLoadingState />
) : resultData ? (
<>
<div>
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</div>
<BrandLookupResults result={resultData} />
</>
) : !errorMessage ? (
<BrandLookupHistorySection
history={history}
historyLoaded={historyLoaded}
onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleSelectHistoryItem}
/>
) : null}
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,256 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import {
AlertCircle,
ArrowLeft,
Columns3,
SearchCheck,
Sparkles,
} from "lucide-react";
import { explorePrompt } from "@/serverFunctions/ai-search";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { PromptExplorerForm } from "@/client/features/ai-search/components/PromptExplorerForm";
import { PromptExplorerResults } from "@/client/features/ai-search/components/PromptExplorerResults";
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import {
usePromptExplorerSearchHistory,
type PromptExplorerSearchHistoryItem,
} from "@/client/hooks/usePromptExplorerSearchHistory";
import {
PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
PROMPT_EXPLORER_MODELS,
type PromptExplorerModel,
type WebSearchCountryCode,
} from "@/types/schemas/ai-search";
type Props = {
projectId: string;
};
const PROMPT_EXPLORER_BULLETS = [
{
icon: Columns3,
title: "Four models side-by-side",
body: "Run one prompt across ChatGPT, Claude, Gemini, and Perplexity and compare answers in a single view.",
},
{
icon: SearchCheck,
title: "See what the models cite",
body: "Every answer lists the sources it drew from, so you can audit where each model gets its information.",
},
{
icon: Sparkles,
title: "Check brand mentions",
body: "Highlight a brand to instantly see whether it shows up in the answer text or the cited sources.",
},
];
type FormState = {
prompt: string;
highlightBrand: string;
models: PromptExplorerModel[];
webSearch: boolean;
webSearchCountryCode: WebSearchCountryCode;
};
const INITIAL_FORM_STATE: FormState = {
prompt: "",
highlightBrand: "",
models: [...PROMPT_EXPLORER_MODELS],
webSearch: true,
webSearchCountryCode: "US",
};
export function PromptExplorerPage(props: Props) {
return (
<AutumnProvider>
<PromptExplorerPageInner {...props} />
</AutumnProvider>
);
}
function PromptExplorerPageInner({ projectId }: Props) {
const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE);
const [validationError, setValidationError] = useState<string | null>(null);
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const planKnown = customerQuery.isSuccess || customerQuery.isError;
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const {
history,
isLoaded: historyLoaded,
addSearch,
removeHistoryItem,
} = usePromptExplorerSearchHistory(projectId);
const exploreMutation = useMutation({
mutationFn: (input: FormState) =>
explorePrompt({
data: {
projectId,
prompt: input.prompt,
models: input.models,
highlightBrand:
input.highlightBrand.length > 0 ? input.highlightBrand : undefined,
webSearch: input.webSearch,
webSearchCountryCode: input.webSearchCountryCode,
},
}),
});
const runExplore = (values: FormState) => {
const normalized: FormState = {
...values,
prompt: values.prompt.trim(),
highlightBrand: values.highlightBrand.trim(),
};
addSearch({
prompt: normalized.prompt,
highlightBrand: normalized.highlightBrand,
models: normalized.models,
webSearch: normalized.webSearch,
webSearchCountryCode: normalized.webSearchCountryCode,
});
exploreMutation.mutate(normalized);
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const trimmedPrompt = form.prompt.trim();
if (trimmedPrompt.length === 0) {
setValidationError("Enter a prompt");
return;
}
if (trimmedPrompt.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) {
setValidationError(
`Keep prompts under ${PROMPT_EXPLORER_MAX_PROMPT_LENGTH} characters`,
);
return;
}
if (form.models.length === 0) {
setValidationError("Select at least one model");
return;
}
setValidationError(null);
runExplore(form);
};
const handleSelectHistoryItem = (item: PromptExplorerSearchHistoryItem) => {
const nextForm: FormState = {
prompt: item.prompt,
highlightBrand: item.highlightBrand,
models: item.models,
webSearch: item.webSearch,
webSearchCountryCode: item.webSearchCountryCode,
};
setForm(nextForm);
setValidationError(null);
runExplore(nextForm);
};
const handleShowRecentSearches = () => {
exploreMutation.reset();
setForm(INITIAL_FORM_STATE);
setValidationError(null);
};
const errorMessage = exploreMutation.isError
? getStandardErrorMessage(exploreMutation.error)
: null;
const updateForm = <K extends keyof FormState>(
key: K,
value: FormState[K],
) => {
setForm((prev) => ({ ...prev, [key]: value }));
if (validationError) setValidationError(null);
};
if (!planKnown) return null;
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Prompt Explorer</h1>
<p className="text-sm text-base-content/70">
Ask any prompt across ChatGPT, Claude, Gemini, and Perplexity
side-by-side.
</p>
</div>
{isFreePlan ? (
<AiSearchPaidPlanGate
feature="Prompt Explorer"
description="Ask one prompt across ChatGPT, Claude, Gemini, and Perplexity at the same time and compare their answers — including which sources each model cites."
bullets={PROMPT_EXPLORER_BULLETS}
/>
) : (
<>
<PromptExplorerForm
form={form}
onPromptChange={(value) => updateForm("prompt", value)}
onHighlightBrandChange={(value) =>
updateForm("highlightBrand", value)
}
onModelsChange={(value) => updateForm("models", value)}
onWebSearchChange={(value) => updateForm("webSearch", value)}
onCountryChange={(value) =>
updateForm("webSearchCountryCode", value)
}
onSubmit={handleSubmit}
isLoading={exploreMutation.isPending}
validationError={validationError}
/>
{errorMessage ? (
<div
role="alert"
className="flex items-start gap-2 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
) : null}
{exploreMutation.isPending ? (
<PromptExplorerLoadingState modelCount={form.models.length} />
) : exploreMutation.data ? (
<>
<div>
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</div>
<PromptExplorerResults result={exploreMutation.data} />
</>
) : !errorMessage ? (
<PromptExplorerHistorySection
history={history}
historyLoaded={historyLoaded}
onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleSelectHistoryItem}
/>
) : null}
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
export type CitationTab = "pages" | "queries";
export type TopPagesFilterValues = {
include: string;
exclude: string;
platform: string;
minMentions: string;
maxMentions: string;
};
export type QueriesFilterValues = {
include: string;
exclude: string;
platform: string;
minVolume: string;
maxVolume: string;
};
export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = {
include: "",
exclude: "",
platform: "",
minMentions: "",
maxMentions: "",
};
export const EMPTY_QUERIES_FILTERS: QueriesFilterValues = {
include: "",
exclude: "",
platform: "",
minVolume: "",
maxVolume: "",
};

View File

@ -0,0 +1,93 @@
import { parseTerms } from "@/client/features/keywords/utils";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
import type {
QueriesFilterValues,
TopPagesFilterValues,
} from "./brandLookupFilterTypes";
function passesNumericFilter(
value: number | null | undefined,
min: string,
max: string,
): boolean {
if (value == null) return true;
const minN = Number(min);
if (min && !Number.isNaN(minN) && value < minN) return false;
const maxN = Number(max);
if (max && !Number.isNaN(maxN) && value > maxN) return false;
return true;
}
function passesTextFilter(
haystack: string,
includeTerms: string[],
excludeTerms: string[],
): boolean {
const lower = haystack.toLowerCase();
if (
includeTerms.length > 0 &&
!includeTerms.some((term) => lower.includes(term))
) {
return false;
}
if (excludeTerms.some((term) => lower.includes(term))) {
return false;
}
return true;
}
export function filterTopPages(
rows: BrandLookupResult["topPages"],
filters: TopPagesFilterValues,
): BrandLookupResult["topPages"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
const textFields = [row.url, row.domain]
.filter((v): v is string => Boolean(v))
.join(" ");
if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false;
if (filters.platform && row.platform !== filters.platform) return false;
if (
!passesNumericFilter(
row.mentions,
filters.minMentions,
filters.maxMentions,
)
) {
return false;
}
return true;
});
}
export function filterQueries(
rows: BrandLookupResult["topQueries"],
filters: QueriesFilterValues,
): BrandLookupResult["topQueries"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
const textFields = [row.question, ...row.brandsMentioned].join(" ");
if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false;
if (filters.platform && row.platform !== filters.platform) return false;
if (
!passesNumericFilter(
row.aiSearchVolume,
filters.minVolume,
filters.maxVolume,
)
) {
return false;
}
return true;
});
}
export function countActiveFilters(values: Record<string, string>): number {
return Object.values(values).filter((v) => v.trim() !== "").length;
}

View File

@ -0,0 +1,29 @@
export function AiSearchLoadingState() {
return (
<div className="space-y-8" aria-busy>
<div className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-base-300 bg-base-300 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-2 bg-base-100 p-5">
<div className="skeleton h-3 w-24" />
<div className="skeleton h-8 w-32" />
<div className="skeleton h-3 w-40" />
</div>
))}
</div>
<div className="space-y-3">
<div className="skeleton h-4 w-32" />
<div className="space-y-2 rounded-lg border border-base-300 p-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="grid grid-cols-6 gap-3">
<div className="skeleton col-span-3 h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,49 @@
import { Link } from "@tanstack/react-router";
import { Sparkles, type LucideIcon } from "lucide-react";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
type Props = {
feature: string;
description: string;
bullets: Array<{ icon: LucideIcon; title: string; body: string }>;
};
export function AiSearchPaidPlanGate({ feature, description, bullets }: Props) {
return (
<div className="mx-auto max-w-3xl overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
<div className="flex flex-col gap-5 px-6 py-6 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-xl space-y-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
<Sparkles className="size-3.5" />
Paid plan
</span>
<h2 className="text-xl font-semibold tracking-tight">
Unlock {feature}
</h2>
<p className="text-sm text-base-content/70">{description}</p>
</div>
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="btn btn-primary shrink-0"
>
Upgrade
</Link>
</div>
<div className="grid grid-cols-1 gap-5 border-t border-base-300 px-6 py-6 sm:grid-cols-3">
{bullets.map(({ icon: Icon, title, body }) => (
<div key={title} className="space-y-2">
<div className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs leading-relaxed text-base-content/65">
{body}
</p>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,210 @@
import {
createColumnHelper,
flexRender,
type Table,
} from "@tanstack/react-table";
import { ExternalLink } from "lucide-react";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
import {
formatCount,
formatPlatformLabel,
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type TopPageRow = BrandLookupResult["topPages"][number];
type TopQueryRow = BrandLookupResult["topQueries"][number];
type PlatformKey = TopPageRow["platform"];
const PLATFORM_BADGE_CLASS: Record<PlatformKey, string> = {
chat_gpt: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
google: "border-sky-500/40 bg-sky-500/10 text-sky-500",
};
function PlatformBadge({ platform }: { platform: PlatformKey }) {
return (
<span className={`badge badge-sm border ${PLATFORM_BADGE_CLASS[platform]}`}>
{formatPlatformLabel(platform)}
</span>
);
}
const pagesHelper = createColumnHelper<TopPageRow>();
const queriesHelper = createColumnHelper<TopQueryRow>();
export const topPagesColumns = [
pagesHelper.accessor("url", {
id: "url",
header: () => <span className="uppercase tracking-wider">URL</span>,
enableSorting: false,
cell: ({ row }) => (
<>
<a
href={row.original.url}
target="_blank"
rel="noreferrer"
className="link link-primary inline-flex items-start gap-1 break-all"
>
<span className="break-all">
{formatUrlForDisplay(row.original.url)}
</span>
<ExternalLink className="mt-1 size-3 shrink-0" />
</a>
{row.original.domain ? (
<p className="text-xs text-base-content/50">{row.original.domain}</p>
) : null}
</>
),
}),
pagesHelper.accessor("platform", {
id: "platform",
header: () => <span className="uppercase tracking-wider">Platform</span>,
enableSorting: false,
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />,
}),
pagesHelper.accessor("mentions", {
id: "mentions",
header: ({ column }) => (
<SortableHeader column={column} label="Mentions" align="right" />
),
cell: ({ getValue }) => (
<span className="tabular-nums">{formatCount(getValue())}</span>
),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
];
export const topQueriesColumns = [
queriesHelper.accessor("question", {
id: "question",
header: () => <span className="uppercase tracking-wider">Query</span>,
enableSorting: false,
cell: ({ row }) => (
<>
<p className="break-words font-medium">{row.original.question}</p>
{row.original.brandsMentioned.length > 0 ? (
<p className="mt-0.5 text-xs text-base-content/50">
Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")}
</p>
) : null}
</>
),
}),
queriesHelper.accessor("platform", {
id: "platform",
header: () => <span className="uppercase tracking-wider">Platform</span>,
enableSorting: false,
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />,
}),
queriesHelper.accessor("aiSearchVolume", {
id: "aiSearchVolume",
header: ({ column }) => (
<SortableHeader column={column} label="AI search vol." align="right" />
),
cell: ({ getValue }) => (
<span className="tabular-nums">{formatCount(getValue())}</span>
),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
];
export function TopPagesTable({ table }: { table: Table<TopPageRow> }) {
if (table.getRowModel().rows.length === 0) {
return (
<p className="p-6 text-center text-sm text-base-content/60">
No cited pages returned.
</p>
);
}
return <BrandLookupTable table={table} urlLikeColumnId="url" />;
}
export function TopQueriesTable({ table }: { table: Table<TopQueryRow> }) {
if (table.getRowModel().rows.length === 0) {
return (
<p className="p-6 text-center text-sm text-base-content/60">
No matching queries found.
</p>
);
}
return <BrandLookupTable table={table} urlLikeColumnId="question" />;
}
function BrandLookupTable<T>({
table,
urlLikeColumnId,
}: {
table: Table<T>;
urlLikeColumnId: string;
}) {
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isNumeric = header.column.getCanSort();
return (
<th
key={header.id}
className={`text-xs uppercase tracking-wider text-base-content/60 ${
isNumeric ? "text-right" : ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
const isNumeric = cell.column.getCanSort();
return (
<td
key={cell.id}
className={cellClassName(
cell.column.id,
urlLikeColumnId,
isNumeric,
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
function cellClassName(
columnId: string,
urlLikeColumnId: string,
isNumeric: boolean,
): string {
if (columnId === urlLikeColumnId) {
return "min-w-80 max-w-2xl align-top";
}
if (isNumeric) {
return "whitespace-nowrap text-right align-top";
}
return "whitespace-nowrap align-top";
}

View File

@ -0,0 +1,235 @@
import { RotateCcw } from "lucide-react";
import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes";
import { formatPlatformLabel } from "@/client/features/ai-search/platformLabels";
import type { BrandLookupFiltersState } from "@/client/features/ai-search/useBrandLookupFilters";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyForm = { Field: React.ComponentType<any> };
function FilterTextInput({
form,
name,
label,
placeholder,
}: {
form: AnyForm;
name: string;
label: string;
placeholder: string;
}) {
return (
<label className="form-control gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<form.Field name={name}>
{(field: {
state: { value: string };
handleChange: (v: string) => void;
}) => (
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder={placeholder}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</label>
);
}
function FilterRangeInputs({
form,
title,
minName,
maxName,
}: {
form: AnyForm;
title: string;
minName: string;
maxName: string;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{title}
</p>
<div className="grid grid-cols-2 gap-2">
<CompactRangeInput form={form} name={minName} placeholder="Min" />
<CompactRangeInput form={form} name={maxName} placeholder="Max" />
</div>
</div>
);
}
function CompactRangeInput({
form,
name,
placeholder,
}: {
form: AnyForm;
name: string;
placeholder: string;
}) {
return (
<form.Field name={name}>
{(field: {
state: { value: string };
handleChange: (v: string) => void;
}) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder={placeholder}
type="number"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
);
}
function PlatformToggle({ form }: { form: AnyForm }) {
return (
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Platform
</p>
<form.Field name="platform">
{(field: {
state: { value: string };
handleChange: (v: string) => void;
}) => (
<div className="flex flex-wrap items-center gap-1">
{(["", "chat_gpt", "google"] as const).map((value) => (
<button
key={value || "all"}
type="button"
className={`btn btn-xs ${field.state.value === value ? "btn-soft" : "btn-ghost"}`}
onClick={() => field.handleChange(value)}
>
{value === "" ? "All" : formatPlatformLabel(value)}
</button>
))}
</div>
)}
</form.Field>
</div>
);
}
function TopPagesFilters({
form,
}: {
form: BrandLookupFiltersState["pages"]["form"];
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="reddit, forbes"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="pinterest, /tag"
/>
</div>
<div className="flex flex-wrap items-end gap-4">
<PlatformToggle form={form} />
<div className="min-w-[220px]">
<FilterRangeInputs
form={form}
title="Mentions"
minName="minMentions"
maxName="maxMentions"
/>
</div>
</div>
</>
);
}
function QueriesFilters({
form,
}: {
form: BrandLookupFiltersState["queries"]["form"];
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="pricing, reviews"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="login, download"
/>
</div>
<div className="flex flex-wrap items-end gap-4">
<PlatformToggle form={form} />
<div className="min-w-[220px]">
<FilterRangeInputs
form={form}
title="AI search volume"
minName="minVolume"
maxName="maxVolume"
/>
</div>
</div>
</>
);
}
export function BrandLookupFilterPanel({
activeTab,
filters,
}: {
activeTab: CitationTab;
filters: BrandLookupFiltersState;
}) {
const current = filters[activeTab];
return (
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine results</p>
{current.activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{current.activeFilterCount} active
</span>
) : null}
</div>
<button
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={current.reset}
disabled={current.activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</div>
{activeTab === "pages" ? (
<TopPagesFilters form={filters.pages.form} />
) : null}
{activeTab === "queries" ? (
<QueriesFilters form={filters.queries.form} />
) : null}
</div>
);
}

View File

@ -0,0 +1,24 @@
import { Sparkles } from "lucide-react";
import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection";
import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory";
type Props = {
history: BrandLookupSearchHistoryItem[];
historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: BrandLookupSearchHistoryItem) => void;
};
export function BrandLookupHistorySection(props: Props) {
return (
<SearchHistorySection
{...props}
emptyIcon={Sparkles}
emptyMessage="Search a brand name or domain to see how AI cites it"
noun="lookup"
renderItem={(item) => (
<p className="font-medium text-base-content truncate">{item.query}</p>
)}
/>
);
}

View File

@ -0,0 +1,95 @@
import { useMemo } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatCount } from "@/client/features/ai-search/platformLabels";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type Props = {
result: BrandLookupResult;
};
export function BrandLookupMentionTrendCard({ result }: Props) {
const chartData = useMemo(
() =>
result.monthlyVolume.map((entry) => ({
label: `${entry.year}-${String(entry.month).padStart(2, "0")}`,
volume: entry.volume ?? 0,
})),
[result.monthlyVolume],
);
if (chartData.length === 0) {
return (
<div className="flex h-56 items-center justify-center text-sm text-base-content/60">
Not enough historical data yet.
</div>
);
}
return (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 12, right: 12, bottom: 4, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
opacity={0.12}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "#888" }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "#888" }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
content={<MentionTooltip />}
cursor={{ stroke: "currentColor", strokeOpacity: 0.2 }}
/>
<Line
type="monotone"
dataKey="volume"
stroke="hsl(220 70% 50%)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
function MentionTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ value: number }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-md border border-base-300 bg-base-100 px-3 py-2 shadow-sm">
<p className="text-xs text-base-content/60">{label}</p>
<p className="text-sm font-medium tabular-nums">
{formatCount(payload[0].value)} mentions
</p>
</div>
);
}

View File

@ -0,0 +1,420 @@
import { useMemo, useState } from "react";
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { Download, Info, SlidersHorizontal } from "lucide-react";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
import {
TopPagesTable,
TopQueriesTable,
topPagesColumns,
topQueriesColumns,
} from "@/client/features/ai-search/components/BrandLookupCitationTables";
import {
formatCount,
formatPlatformLabel,
} from "@/client/features/ai-search/platformLabels";
import {
filterQueries,
filterTopPages,
} from "@/client/features/ai-search/brandLookupFiltering";
import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters";
import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type Props = {
result: BrandLookupResult;
};
type PlatformRow = BrandLookupResult["perPlatform"][number];
type MetricKey = "mentions" | "aiSearchVolume" | "impressions";
const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = {
chat_gpt: "bg-emerald-500",
google: "bg-sky-500",
};
export function BrandLookupResults({ result }: Props) {
const erroredPlatforms = result.perPlatform.filter(
(p) => p.status === "error",
);
const allPlatformsErrored =
erroredPlatforms.length === result.perPlatform.length &&
result.perPlatform.length > 0;
if (!result.hasData) {
if (allPlatformsErrored) {
return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm">
AI mention data is temporarily unavailable for{" "}
<strong>{result.resolvedTarget}</strong>. Please try again shortly.
</div>
);
}
return (
<div className="space-y-3">
<div className="rounded-lg border border-info/30 bg-info/10 p-4 text-sm">
No AI mentions found for <strong>{result.resolvedTarget}</strong>.
</div>
{erroredPlatforms.length > 0 ? (
<p className="text-xs text-base-content/60">
Note: {formatPlatformList(erroredPlatforms.map((p) => p.platform))}{" "}
{erroredPlatforms.length === 1 ? "was" : "were"} unavailable some
mentions may be missing.
</p>
) : null}
</div>
);
}
const hasTrendData = result.monthlyVolume.length > 0;
return (
<div className="space-y-6">
<BrandHeader result={result} />
<div
className={`grid gap-4 ${hasTrendData ? "lg:grid-cols-2" : "grid-cols-1"}`}
>
<KpiTiles result={result} />
{hasTrendData ? <MentionTrendCard result={result} /> : null}
</div>
<CitationTabsCard result={result} />
</div>
);
}
function formatPlatformList(platforms: PlatformRow["platform"][]): string {
return platforms.map(formatPlatformLabel).join(" and ");
}
function BrandHeader({ result }: { result: BrandLookupResult }) {
return (
<section className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="text-3xl font-semibold tracking-tight">
{result.resolvedTarget}
</h2>
<span className="badge badge-ghost badge-sm">
{result.detectedTargetType}
</span>
</div>
<p className="text-xs text-base-content/50">
Updated {formatRelative(result.fetchedAt)}
</p>
</section>
);
}
function KpiTiles({ result }: { result: BrandLookupResult }) {
return (
<section className="flex flex-col divide-y divide-base-200 overflow-hidden rounded-xl border border-base-300 bg-base-100">
<KpiTile
label="Total mentions"
tooltip="Number of LLM answers where your domain appeared in the text or citations."
total={result.totalMentions}
perPlatform={result.perPlatform}
metric="mentions"
/>
<KpiTile
label="AI search volume"
tooltip="Monthly volume of user prompts on topics where your domain shows up in LLM answers."
total={result.totalAiSearchVolume}
perPlatform={result.perPlatform}
metric="aiSearchVolume"
/>
<KpiTile
label="Estimated impressions"
tooltip="How often your domain is shown to users across LLM answers, based on mention frequency and topic search volume."
total={result.totalImpressions}
perPlatform={result.perPlatform}
metric="impressions"
/>
</section>
);
}
function KpiTile({
label,
tooltip,
total,
perPlatform,
metric,
}: {
label: string;
tooltip: string;
total: number | null;
perPlatform: PlatformRow[];
metric: MetricKey;
}) {
return (
<div className="flex flex-1 items-center justify-between gap-6 px-5 py-3">
<div className="min-w-0">
<p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50">
{label}
<span
className="tooltip tooltip-right inline-flex normal-case"
data-tip={tooltip}
>
<Info className="size-3 text-base-content/40" />
</span>
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{formatCount(total)}
</p>
</div>
<div className="flex shrink-0 flex-col gap-1.5 min-w-[12rem]">
{perPlatform.map((row) => (
<PlatformStatRow key={row.platform} row={row} metric={metric} />
))}
</div>
</div>
);
}
function PlatformStatRow({
row,
metric,
}: {
row: PlatformRow;
metric: MetricKey;
}) {
const value = row.status === "error" ? null : row[metric];
return (
<div className="flex items-center justify-between text-xs">
<span className="inline-flex items-center gap-1.5 text-base-content/70">
<span
className={`size-1.5 rounded-full ${PLATFORM_DOT_CLASS[row.platform]}`}
/>
{formatPlatformLabel(row.platform)}
{row.platform === "chat_gpt" ? (
<span
className="tooltip tooltip-right inline-flex"
data-tip="DataForSEO indexes ChatGPT mentions for US English only — country selection is not available for this platform."
>
<Info className="size-3 text-base-content/40" />
</span>
) : null}
{row.status === "error" ? (
<span className="text-error">unavailable</span>
) : null}
</span>
<span className="font-medium tabular-nums text-base-content/90">
{formatCount(value)}
</span>
</div>
);
}
function MentionTrendCard({ result }: { result: BrandLookupResult }) {
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="border-b border-base-300 px-4 py-3">
<h3 className="text-sm font-semibold">
Mention trend (last 12 months)
</h3>
</div>
<div className="p-4">
<BrandLookupMentionTrendCard result={result} />
</div>
</section>
);
}
const DEFAULT_PAGES_SORT: SortingState = [{ id: "mentions", desc: true }];
const DEFAULT_QUERIES_SORT: SortingState = [
{ id: "aiSearchVolume", desc: true },
];
function CitationTabsCard({ result }: { result: BrandLookupResult }) {
const [activeTab, setActiveTab] = useState<CitationTab>("queries");
const [pagesSort, setPagesSort] = useState<SortingState>(DEFAULT_PAGES_SORT);
const [queriesSort, setQueriesSort] =
useState<SortingState>(DEFAULT_QUERIES_SORT);
const filters = useBrandLookupFilters();
const filteredPages = useMemo(
() => filterTopPages(result.topPages, filters.pages.values),
[result.topPages, filters.pages.values],
);
const filteredQueries = useMemo(
() => filterQueries(result.topQueries, filters.queries.values),
[result.topQueries, filters.queries.values],
);
const pagesTable = useReactTable({
data: filteredPages,
columns: topPagesColumns,
state: { sorting: pagesSort },
onSortingChange: setPagesSort,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const queriesTable = useReactTable({
data: filteredQueries,
columns: topQueriesColumns,
state: { sorting: queriesSort },
onSortingChange: setQueriesSort,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const handleExport = () => {
if (activeTab === "pages") {
const sortedPages = pagesTable
.getSortedRowModel()
.rows.map((row) => row.original);
const csv = buildCsv(
["URL", "Domain", "Platform", "Mentions"],
sortedPages.map((row) => [
row.url,
row.domain ?? "",
formatPlatformLabel(row.platform),
row.mentions ?? "",
]),
);
downloadCsv(
`ai-brand-lookup-pages-${slugify(result.resolvedTarget)}.csv`,
csv,
);
return;
}
const sortedQueries = queriesTable
.getSortedRowModel()
.rows.map((row) => row.original);
const csv = buildCsv(
["Query", "Platform", "AI search volume", "First seen", "Last seen"],
sortedQueries.map((row) => [
row.question,
formatPlatformLabel(row.platform),
row.aiSearchVolume ?? "",
row.firstSeenAt ?? "",
row.lastSeenAt ?? "",
]),
);
downloadCsv(
`ai-brand-lookup-queries-${slugify(result.resolvedTarget)}.csv`,
csv,
);
};
const canExport =
activeTab === "pages"
? filteredPages.length > 0
: filteredQueries.length > 0;
const currentFilterCount = filters[activeTab].activeFilterCount;
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3">
<div role="tablist" className="tabs tabs-box w-fit">
<button
type="button"
role="tab"
className={`tab ${activeTab === "queries" ? "tab-active" : ""}`}
onClick={() => setActiveTab("queries")}
>
Queries
</button>
<button
type="button"
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => setActiveTab("pages")}
>
Related pages
</button>
</div>
<button
type="button"
className="btn btn-ghost btn-sm gap-1.5"
onClick={handleExport}
disabled={!canExport}
aria-label="Export current tab as CSV"
>
<Download className="size-3.5" />
Export CSV
</button>
</div>
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
<button
type="button"
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
onClick={() => filters.setShowFilters((current) => !current)}
title="Toggle table filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{currentFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{currentFilterCount}
</span>
) : null}
</button>
</div>
<div className="border-b border-base-300 px-4 py-2 text-xs text-base-content/60">
{activeTab === "pages" ? (
<>
Other pages LLMs cited in the same answers that referenced{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>
. Useful for spotting the sources competing for attention alongside
your domain.
</>
) : (
<>
User prompts where the LLM's answer referenced{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>{" "}
in its text or citations. The prompt itself does not have to mention
your domain.
</>
)}
</div>
{filters.showFilters ? (
<BrandLookupFilterPanel activeTab={activeTab} filters={filters} />
) : null}
{activeTab === "pages" ? (
<TopPagesTable table={pagesTable} />
) : (
<TopQueriesTable table={queriesTable} />
)}
</section>
);
}
function formatRelative(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "just now";
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d ago`;
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}

View File

@ -0,0 +1,90 @@
import type { FormEvent } from "react";
import { Search } from "lucide-react";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { applyBillingMarkupUsd } from "@/shared/billing";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
type Props = {
query: string;
onQueryChange: (next: string) => void;
onSubmit: (event: FormEvent) => void;
isLoading: boolean;
validationError: string | null;
};
/**
* One brand lookup = 6 DataForSEO calls (3 endpoints × 2 platforms). Measured
* live at ~$0.634 raw via `pnpm billing:brand-lookup`; rounded up to leave
* headroom for per-query variance.
*/
const BRAND_LOOKUP_RAW_COST_USD = 0.65;
// Hosted customers are billed the marked-up USD; self-hosted users pay
// DataForSEO directly at the raw rate.
const BRAND_LOOKUP_DISPLAYED_COST_USD = isHostedClientAuthMode()
? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD)
: BRAND_LOOKUP_RAW_COST_USD;
export function BrandLookupSearchCard({
query,
onQueryChange,
onSubmit,
isLoading,
validationError,
}: Props) {
return (
<div className="card border border-base-300 bg-base-100">
<div className="card-body gap-4">
<form
onSubmit={onSubmit}
className="flex flex-col gap-3 lg:flex-row lg:items-center"
>
<label
className={`input input-bordered flex flex-1 items-center gap-2 ${
validationError ? "input-error" : ""
}`}
>
<Search className="size-4 text-base-content/60" />
<input
type="text"
placeholder="Enter a brand name or domain"
value={query}
maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH}
onChange={(event) => onQueryChange(event.target.value)}
aria-invalid={validationError ? true : undefined}
aria-describedby={
validationError ? "brand-lookup-input-error" : undefined
}
autoComplete="off"
spellCheck={false}
className="grow"
/>
</label>
<button
type="submit"
className="btn btn-primary px-6"
disabled={isLoading}
>
{isLoading ? "Looking up..." : "Look up"}
</button>
</form>
{validationError ? (
<p id="brand-lookup-input-error" className="text-sm text-error">
{validationError}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-base-content/60">
<p className="tabular-nums">
Est.{" "}
<span className="font-medium text-base-content/80">
${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)}
</span>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,282 @@
import {
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
type Props = {
text: string;
};
/**
* Collapsed-state max height in px. Roughly 9 lines of body text enough
* to convey the shape of an answer without dominating the page when four
* models are stacked.
*/
const COLLAPSED_MAX_PX = 240;
/**
* Render an LLM's markdown answer with explicit per-element Tailwind classes.
*
* Long answers collapse to ~12 lines with a fade-out gradient and a
* "Read more" toggle so a side-by-side comparison of four models stays
* scannable. We measure the rendered scroll height to decide whether the
* toggle is needed.
*
* Anchor URLs are sanitized to http(s) only LLMs can be coaxed into
* emitting `javascript:` payloads.
*/
export function MarkdownAnswer({ text }: Props) {
const contentRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [needsCollapse, setNeedsCollapse] = useState(false);
const { thinking, body } = extractThinkingBlocks(text);
const normalized = normalizeLlmMarkdown(body);
useLayoutEffect(() => {
const el = contentRef.current;
if (!el) return;
// scrollHeight reflects natural content height even when overflow is
// clipped by max-h, so we can detect overflow without toggling state.
setNeedsCollapse(el.scrollHeight > COLLAPSED_MAX_PX + 8);
}, [normalized]);
if (normalized.trim().length === 0 && thinking.length === 0) {
return (
<p className="text-sm text-base-content/60 italic">
Model returned an empty response.
</p>
);
}
const isCollapsed = needsCollapse && !expanded;
return (
<div className="text-sm leading-relaxed">
{thinking.map((block, index) => (
<ThinkingBlock key={index} text={block} />
))}
{normalized.trim().length > 0 ? (
<div className="relative">
<div
ref={contentRef}
style={
isCollapsed ? { maxHeight: `${COLLAPSED_MAX_PX}px` } : undefined
}
className={isCollapsed ? "overflow-hidden" : undefined}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={MARKDOWN_COMPONENTS}
>
{normalized}
</Markdown>
</div>
{isCollapsed ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-base-100 to-transparent"
/>
) : null}
</div>
) : null}
{needsCollapse ? (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
aria-expanded={expanded}
>
{expanded ? (
<>
<ChevronUp className="size-3.5" />
Show less
</>
) : (
<>
<ChevronDown className="size-3.5" />
Read more
</>
)}
</button>
) : null}
</div>
);
}
function ThinkingBlock({ text }: { text: string }) {
return (
<details
open
className="group mb-3 rounded-lg border border-base-300 bg-base-200/40"
>
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-medium text-base-content/70 hover:text-base-content">
<ChevronDown className="size-3.5 transition-transform group-open:rotate-180" />
Model Thinking
</summary>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-b-lg border-t border-base-300 bg-base-200/60 px-3 py-2.5 text-xs font-mono text-base-content/80">
{text}
</pre>
</details>
);
}
/**
* Reasoning models (e.g. Perplexity sonar-reasoning-pro) wrap their chain of
* thought in `<think>...</think>` tags inline with the answer. Pull those out
* so we can render them in a separate, collapsible block.
*
* Tolerates an unclosed final `<think>` (e.g. from a truncated stream) by
* treating everything after it as a thinking block.
*/
function extractThinkingBlocks(text: string): {
thinking: string[];
body: string;
} {
const thinking: string[] = [];
let body = text;
body = body.replace(/<think>([\s\S]*?)<\/think>/gi, (_, inner: string) => {
thinking.push(inner.trim());
return "";
});
body = body.replace(/<think>([\s\S]*)$/i, (_, inner: string) => {
thinking.push(inner.trim());
return "";
});
return { thinking, body };
}
/**
* Fix a class of malformed markdown we see from LLM responses: a list marker
* (`-`, `*`, `+`, or `1.`) on a line by itself, followed by a blank line,
* followed by the actual item content as a separate paragraph. Default
* markdown correctly renders that as an empty bullet + detached paragraph,
* which looks broken. Collapse the blank line so the marker and content
* form a proper list item.
*/
function normalizeLlmMarkdown(text: string): string {
return text.replace(
/^([ \t]*)([-*+]|\d+\.)[ \t]*\r?\n[ \t]*\r?\n(?=\S)(?![ \t]*(?:[-*+]|\d+\.)[ \t])/gm,
"$1$2 ",
);
}
type AnchorProps = ComponentPropsWithoutRef<"a">;
function SafeAnchor({ href, children, ...rest }: AnchorProps) {
const safeHref = isHttpUrl(href) ? href : undefined;
if (!safeHref) {
return <span className="underline decoration-dotted">{children}</span>;
}
return (
<a
{...rest}
href={safeHref}
target="_blank"
rel="noreferrer"
className="link link-primary"
>
{children}
</a>
);
}
function isHttpUrl(value: string | undefined): value is string {
if (!value) return false;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
// Mirror server-side `safeHttpUrl` — a `user:pass@host` URL shows one
// hostname in link text while auth hits another.
if (url.username || url.password) return false;
return true;
} catch {
return false;
}
}
const MARKDOWN_COMPONENTS = {
h1: ({ children }: { children?: ReactNode }) => (
<h1 className="mt-4 mb-2 text-base font-semibold first:mt-0">{children}</h1>
),
h2: ({ children }: { children?: ReactNode }) => (
<h2 className="mt-4 mb-2 text-sm font-semibold first:mt-0">{children}</h2>
),
h3: ({ children }: { children?: ReactNode }) => (
<h3 className="mt-3 mb-1.5 text-sm font-semibold first:mt-0">{children}</h3>
),
h4: ({ children }: { children?: ReactNode }) => (
<h4 className="mt-3 mb-1 text-sm font-semibold first:mt-0">{children}</h4>
),
p: ({ children }: { children?: ReactNode }) => (
<p className="my-2 first:mt-0 last:mb-0">{children}</p>
),
ul: ({ children }: { children?: ReactNode }) => (
<ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>
),
ol: ({ children }: { children?: ReactNode }) => (
<ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>
),
li: ({ children }: { children?: ReactNode }) => (
<li className="leading-relaxed">{children}</li>
),
a: SafeAnchor,
strong: ({ children }: { children?: ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }: { children?: ReactNode }) => (
<em className="italic">{children}</em>
),
blockquote: ({ children }: { children?: ReactNode }) => (
<blockquote className="my-2 border-l-2 border-base-300 pl-3 text-base-content/80 italic">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-base-300" />,
code: ({ children, className }: ComponentPropsWithoutRef<"code">) => {
// Inline code (no `language-*` className from remark) gets the badge style;
// block code is rendered by `pre` with a different shell.
if (typeof className === "string" && className.startsWith("language-")) {
return <code className={className}>{children}</code>;
}
return (
<code className="rounded bg-base-200 px-1 py-0.5 text-xs font-mono">
{children}
</code>
);
},
pre: ({ children }: { children?: ReactNode }) => (
<pre className="my-2 overflow-x-auto rounded-lg bg-base-200 p-3 text-xs font-mono">
{children}
</pre>
),
table: ({ children }: { children?: ReactNode }) => (
<div className="my-3 overflow-x-auto">
<table className="table table-xs border border-base-300">
{children}
</table>
</div>
),
thead: ({ children }: { children?: ReactNode }) => <thead>{children}</thead>,
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: ReactNode }) => (
<tr className="border-b border-base-300 last:border-0">{children}</tr>
),
th: ({ children }: { children?: ReactNode }) => (
<th className="px-2 py-1.5 text-left font-semibold">{children}</th>
),
td: ({ children }: { children?: ReactNode }) => (
<td className="px-2 py-1.5 align-top">{children}</td>
),
};

View File

@ -0,0 +1,190 @@
import type { FormEvent } from "react";
import {
formatCountryLabel,
formatModelLabel,
} from "@/client/features/ai-search/platformLabels";
import {
PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
PROMPT_EXPLORER_MODELS,
WEB_SEARCH_COUNTRY_CODES,
type PromptExplorerModel,
type WebSearchCountryCode,
} from "@/types/schemas/ai-search";
type FormValues = {
prompt: string;
highlightBrand: string;
models: PromptExplorerModel[];
webSearch: boolean;
webSearchCountryCode: WebSearchCountryCode;
};
type Props = {
form: FormValues;
onPromptChange: (value: string) => void;
onHighlightBrandChange: (value: string) => void;
onModelsChange: (value: PromptExplorerModel[]) => void;
onWebSearchChange: (value: boolean) => void;
onCountryChange: (value: WebSearchCountryCode) => void;
onSubmit: (event: FormEvent) => void;
isLoading: boolean;
validationError: string | null;
};
function isCountryCode(value: string): value is WebSearchCountryCode {
return (WEB_SEARCH_COUNTRY_CODES as readonly string[]).includes(value);
}
function parseCountryCode(value: string): WebSearchCountryCode {
return isCountryCode(value) ? value : "US";
}
export function PromptExplorerForm({
form,
onPromptChange,
onHighlightBrandChange,
onModelsChange,
onWebSearchChange,
onCountryChange,
onSubmit,
isLoading,
validationError,
}: Props) {
const toggleModel = (model: PromptExplorerModel) => {
if (form.models.includes(model)) {
onModelsChange(form.models.filter((m) => m !== model));
} else {
onModelsChange([...form.models, model]);
}
};
const promptCharCount = form.prompt.length;
const promptOverLimit = promptCharCount > PROMPT_EXPLORER_MAX_PROMPT_LENGTH;
return (
<form
onSubmit={onSubmit}
className="card border border-base-300 bg-base-100"
>
<div className="card-body gap-5">
<div className="space-y-1.5">
<label
className="block text-sm font-medium"
htmlFor="prompt-explorer-prompt"
>
Prompt
</label>
<textarea
id="prompt-explorer-prompt"
className={`textarea textarea-bordered w-full resize-none ${
promptOverLimit ? "textarea-error" : ""
}`}
rows={3}
value={form.prompt}
maxLength={PROMPT_EXPLORER_MAX_PROMPT_LENGTH + 50}
onChange={(event) => onPromptChange(event.target.value)}
aria-invalid={promptOverLimit ? true : undefined}
autoFocus
/>
<div className="flex items-center justify-between text-xs text-base-content/60">
<span>What your customers might ask AI.</span>
<span
className={`tabular-nums ${promptOverLimit ? "font-medium text-error" : ""}`}
>
{promptCharCount}/{PROMPT_EXPLORER_MAX_PROMPT_LENGTH}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label
className="block text-sm font-medium"
htmlFor="prompt-explorer-brand"
>
Highlight brand (optional)
</label>
<input
id="prompt-explorer-brand"
type="text"
className="input input-bordered w-full"
value={form.highlightBrand}
onChange={(event) => onHighlightBrandChange(event.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p className="text-xs text-base-content/60">
We&apos;ll flag whether each model mentions this brand.
</p>
</div>
<div className="space-y-1.5">
<span className="block text-sm font-medium">Models</span>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 pt-1.5">
{PROMPT_EXPLORER_MODELS.map((model) => {
const isActive = form.models.includes(model);
return (
<label
key={model}
className="flex cursor-pointer items-center gap-2"
>
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={isActive}
onChange={() => toggleModel(model)}
/>
<span className="text-sm">{formatModelLabel(model)}</span>
</label>
);
})}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-base-300 pt-4">
<div className="flex flex-wrap items-center gap-3">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={form.webSearch}
onChange={(event) => onWebSearchChange(event.target.checked)}
/>
<span className="text-sm">
Allow web search (more current answers)
</span>
</label>
<select
id="prompt-explorer-country"
aria-label="Web search location"
className="select select-bordered select-sm min-w-0 sm:max-w-xs"
value={form.webSearchCountryCode}
onChange={(event) =>
onCountryChange(parseCountryCode(event.target.value))
}
disabled={!form.webSearch}
>
{WEB_SEARCH_COUNTRY_CODES.map((code) => (
<option key={code} value={code}>
{formatCountryLabel(code)}
</option>
))}
</select>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={isLoading || form.models.length === 0}
>
{isLoading ? "Running…" : "Run"}
</button>
</div>
{validationError ? (
<p className="text-sm text-error">{validationError}</p>
) : null}
</div>
</form>
);
}

View File

@ -0,0 +1,32 @@
import { MessageSquare } from "lucide-react";
import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection";
import { formatModelLabel } from "@/client/features/ai-search/platformLabels";
import type { PromptExplorerSearchHistoryItem } from "@/client/hooks/usePromptExplorerSearchHistory";
type Props = {
history: PromptExplorerSearchHistoryItem[];
historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: PromptExplorerSearchHistoryItem) => void;
};
export function PromptExplorerHistorySection(props: Props) {
return (
<SearchHistorySection
{...props}
emptyIcon={MessageSquare}
emptyMessage="Enter a prompt to compare model answers"
noun="prompt"
renderItem={(item) => (
<>
<p className="font-medium text-base-content truncate">
{item.prompt}
</p>
<p className="text-sm text-base-content/60 truncate">
{item.models.map(formatModelLabel).join(", ")}
</p>
</>
)}
/>
);
}

View File

@ -0,0 +1,32 @@
type Props = {
modelCount: number;
};
export function PromptExplorerLoadingState({ modelCount }: Props) {
const count = Math.max(1, modelCount);
return (
<div className="space-y-5" aria-busy>
{Array.from({ length: count }).map((_, index) => (
<article
key={index}
className="overflow-hidden rounded-r-lg border border-base-300 border-l-4 border-l-base-300 bg-base-100"
>
<header className="flex items-center justify-between border-b border-base-200 bg-base-200/40 px-5 py-3">
<div className="flex items-center gap-2">
<div className="skeleton size-2 rounded-full" />
<div className="skeleton h-4 w-20" />
<div className="skeleton h-3 w-32" />
</div>
<div className="skeleton h-3 w-16" />
</header>
<div className="space-y-2 px-5 py-5">
<div className="skeleton h-3 w-full" />
<div className="skeleton h-3 w-11/12" />
<div className="skeleton h-3 w-10/12" />
<div className="skeleton h-3 w-9/12" />
</div>
</article>
))}
</div>
);
}

View File

@ -0,0 +1,214 @@
import {
AlertCircle,
CheckCircle2,
ExternalLink,
Globe,
XCircle,
} from "lucide-react";
import { MarkdownAnswer } from "@/client/features/ai-search/components/MarkdownAnswer";
import {
formatModelLabel,
getModelAccent,
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay";
import type {
PromptExplorerModelResult,
PromptExplorerResult,
} from "@/types/schemas/ai-search";
type Props = {
result: PromptExplorerResult;
};
export function PromptExplorerResults({ result }: Props) {
return (
<div className="space-y-5">
{result.results.map((modelResult) => (
<ModelResultCard
key={modelResult.model}
modelResult={modelResult}
highlightBrand={result.highlightBrand}
/>
))}
</div>
);
}
function ModelResultCard({
modelResult,
highlightBrand,
}: {
modelResult: PromptExplorerModelResult;
highlightBrand: string | null;
}) {
const accent = getModelAccent(modelResult.model);
if (modelResult.status === "error") {
return (
<article
className={`overflow-hidden rounded-r-lg border border-base-300 border-l-4 ${accent.border} bg-base-100`}
>
<ModelHeader
model={modelResult.model}
modelName={null}
tokens={null}
webSearch={false}
brandMentioned={null}
highlightBrand={null}
status="error"
/>
<div className="flex items-start gap-2 px-5 py-4 text-sm text-error">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{modelResult.message}</span>
</div>
</article>
);
}
return (
<article
className={`overflow-hidden rounded-r-lg border border-base-300 border-l-4 ${accent.border} bg-base-100`}
>
<ModelHeader
model={modelResult.model}
modelName={modelResult.modelName}
tokens={modelResult.outputTokens}
webSearch={modelResult.webSearch}
brandMentioned={modelResult.brandMentioned}
highlightBrand={highlightBrand}
status="success"
/>
<div className="px-5 py-5">
<MarkdownAnswer text={modelResult.text} />
</div>
{modelResult.citations.length > 0 ? (
<div className="border-t border-base-200 bg-base-200/30 px-5 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
Cited sources ({modelResult.citations.length})
</p>
<ul className="space-y-1.5">
{modelResult.citations.map((citation, index) => (
<li
key={`${citation.url}-${index}`}
className="flex items-start gap-2 text-sm"
>
<span className="mt-1 size-1 shrink-0 rounded-full bg-base-content/30" />
<a
href={citation.url}
target="_blank"
rel="noreferrer"
className={`link inline-flex items-start gap-1 ${
citation.matchedBrand ? "link-primary font-medium" : ""
}`}
>
<span className="break-all">
{citation.title || formatUrlForDisplay(citation.url)}
</span>
<ExternalLink className="mt-1 size-3 shrink-0" />
</a>
{citation.matchedBrand && highlightBrand ? (
<span className="badge badge-primary badge-xs">
{highlightBrand}
</span>
) : null}
</li>
))}
</ul>
</div>
) : null}
{modelResult.fanOutQueries.length > 0 ? (
<div className="border-t border-base-200 px-5 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
Related queries the model considered
</p>
<div className="flex flex-wrap gap-1.5">
{modelResult.fanOutQueries.map((query, index) => (
<span
key={`${query}-${index}`}
className="rounded-full border border-base-300 px-2.5 py-0.5 text-xs text-base-content/70"
>
{query}
</span>
))}
</div>
</div>
) : null}
</article>
);
}
function ModelHeader({
model,
modelName,
tokens,
webSearch,
brandMentioned,
highlightBrand,
status,
}: {
model: PromptExplorerModelResult["model"];
modelName: string | null;
tokens: number | null;
webSearch: boolean;
brandMentioned: boolean | null;
highlightBrand: string | null;
status: "success" | "error";
}) {
const accent = getModelAccent(model);
return (
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-base-200 bg-base-200/40 px-5 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`size-2 rounded-full ${accent.dot}`} />
<h3 className="text-sm font-semibold">{formatModelLabel(model)}</h3>
{modelName ? (
<code className="text-xs text-base-content/50">{modelName}</code>
) : null}
{status === "error" ? (
<span className="badge badge-error badge-sm">Error</span>
) : null}
<BrandMentionBadge
mentioned={brandMentioned}
highlightBrand={highlightBrand}
/>
{webSearch ? (
<span className="inline-flex items-center gap-1 text-xs text-base-content/60">
<Globe className="size-3" />
web search
</span>
) : null}
</div>
{tokens != null ? (
<span className="text-xs tabular-nums text-base-content/50">
{tokens.toLocaleString()} tokens
</span>
) : null}
</header>
);
}
function BrandMentionBadge({
mentioned,
highlightBrand,
}: {
mentioned: boolean | null;
highlightBrand: string | null;
}) {
if (mentioned == null || !highlightBrand) return null;
if (mentioned) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">
<CheckCircle2 className="size-3" />
{highlightBrand}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-base-200 px-2 py-0.5 text-xs text-base-content/60">
<XCircle className="size-3" />
no {highlightBrand}
</span>
);
}

View File

@ -0,0 +1,94 @@
import type { ComponentType, ReactNode } from "react";
import { Clock, History, X } from "lucide-react";
type Props<TItem extends { timestamp: number }> = {
history: TItem[];
historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: TItem) => void;
/** Icon component rendered in the empty state (e.g. Sparkles, MessageSquare). */
emptyIcon: ComponentType<{ className?: string }>;
/** Empty-state headline copy. */
emptyMessage: string;
/**
* Label noun used in the "{n} recent {noun}(s)" header (e.g. "lookup",
* "prompt"). Pluralization is handled by the component.
*/
noun: string;
/** Item body — primary (and optional secondary) text shown in each row. */
renderItem: (item: TItem) => ReactNode;
};
export function SearchHistorySection<TItem extends { timestamp: number }>({
history,
historyLoaded,
onRemoveHistoryItem,
onSelectHistoryItem,
emptyIcon: EmptyIcon,
emptyMessage,
noun,
renderItem,
}: Props<TItem>) {
if (!historyLoaded) {
return null;
}
if (history.length === 0) {
return (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
<EmptyIcon className="size-9 mx-auto opacity-35" />
<p className="text-base font-medium text-base-content/80">
{emptyMessage}
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<History className="size-4 text-base-content/45" />
<span className="text-sm text-base-content/60">
{history.length} recent {noun}
{history.length !== 1 ? "s" : ""}
</span>
</div>
</div>
<div className="grid gap-2">
{history.map((item) => (
<div
key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
onClick={() => onSelectHistoryItem(item)}
>
<Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0">{renderItem(item)}</div>
</button>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
<button
type="button"
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
onClick={() => onRemoveHistoryItem(item.timestamp)}
aria-label="Remove from history"
>
<X className="size-3" />
</button>
</div>
</div>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,96 @@
import type {
PromptExplorerModel,
WebSearchCountryCode,
} from "@/types/schemas/ai-search";
const MENTION_PLATFORM_LABELS: Record<"chat_gpt" | "google", string> = {
chat_gpt: "ChatGPT",
google: "Google AI Overview",
};
const MODEL_LABELS: Record<PromptExplorerModel, string> = {
chat_gpt: "ChatGPT",
claude: "Claude",
gemini: "Gemini",
perplexity: "Perplexity",
};
/**
* Per-model accent colors. Applied as left-border + dot on response cards so
* the model header is unambiguously separated from the markdown content that
* follows. Values are Tailwind color tokens that work in light + dark themes.
*/
type ModelAccent = {
border: string;
dot: string;
};
const MODEL_ACCENTS: Record<PromptExplorerModel, ModelAccent> = {
chat_gpt: {
border: "border-l-emerald-500",
dot: "bg-emerald-500",
},
claude: {
border: "border-l-orange-500",
dot: "bg-orange-500",
},
gemini: {
border: "border-l-sky-500",
dot: "bg-sky-500",
},
perplexity: {
border: "border-l-violet-500",
dot: "bg-violet-500",
},
};
export function formatPlatformLabel(platform: "chat_gpt" | "google"): string {
return MENTION_PLATFORM_LABELS[platform];
}
export function formatModelLabel(model: PromptExplorerModel): string {
return MODEL_LABELS[model];
}
export function getModelAccent(model: PromptExplorerModel): ModelAccent {
return MODEL_ACCENTS[model];
}
const COUNTRY_LABELS: Record<WebSearchCountryCode, string> = {
US: "United States",
GB: "United Kingdom",
CA: "Canada",
AU: "Australia",
IE: "Ireland",
DE: "Germany",
FR: "France",
ES: "Spain",
IT: "Italy",
NL: "Netherlands",
PT: "Portugal",
PL: "Poland",
SE: "Sweden",
NO: "Norway",
DK: "Denmark",
BR: "Brazil",
MX: "Mexico",
IN: "India",
JP: "Japan",
KR: "South Korea",
SG: "Singapore",
HK: "Hong Kong",
TW: "Taiwan",
ZA: "South Africa",
};
export function formatCountryLabel(code: WebSearchCountryCode): string {
return COUNTRY_LABELS[code];
}
const NUMBER_FORMATTER = new Intl.NumberFormat("en-US");
/** Render a count for display. Null/undefined renders as an em-dash. */
export function formatCount(value: number | null | undefined): string {
if (value == null) return "—";
return NUMBER_FORMATTER.format(value);
}

View File

@ -0,0 +1,24 @@
/**
* Prettify a URL for display: drop Chrome scroll-to-text fragments (`#:~:`)
* and decode percent-encoding so `%20` becomes a space. Google AI Overview
* citations routinely carry 200-char text fragments that are useful in the
* href (they scroll the browser to the cited passage) but pure visual noise
* as link text.
*
* The original URL is still what gets navigated to only the visible text
* changes. Falls back to the raw input if parsing fails.
*/
export function formatUrlForDisplay(value: string): string {
try {
const url = new URL(value);
const hash = url.hash.startsWith("#:~:") ? "" : url.hash;
const cleaned = `${url.protocol}//${url.host}${url.pathname}${url.search}${hash}`;
try {
return decodeURI(cleaned);
} catch {
return cleaned;
}
} catch {
return value;
}
}

View File

@ -0,0 +1,94 @@
import { useCallback, useEffect, useState } from "react";
import { useForm, useStore } from "@tanstack/react-form";
import {
EMPTY_QUERIES_FILTERS,
EMPTY_TOP_PAGES_FILTERS,
type QueriesFilterValues,
type TopPagesFilterValues,
} from "./brandLookupFilterTypes";
import { countActiveFilters } from "./brandLookupFiltering";
const STORAGE_KEY_PREFIX = "brand-lookup-filters:";
type FilterValues = Record<string, string>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function loadFromStorage<T extends FilterValues>(tab: string, fallback: T): T {
const fallbackClone = { ...fallback };
try {
const raw = localStorage.getItem(`${STORAGE_KEY_PREFIX}${tab}`);
if (!raw) return fallbackClone;
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return fallbackClone;
const result = { ...fallbackClone };
for (const key in fallback) {
const value = parsed[key];
if (typeof value === "string") {
Object.assign(result, { [key]: value });
}
}
return result;
} catch {
return fallbackClone;
}
}
function saveToStorage(tab: string, values: FilterValues) {
try {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${tab}`, JSON.stringify(values));
} catch {
// storage full - silently ignore
}
}
function useTabFilters<T extends FilterValues>(tab: string, emptyValues: T) {
const [defaultValues] = useState<T>(() =>
loadFromStorage(tab, { ...emptyValues }),
);
const form = useForm({ defaultValues });
const values = useStore(form.store, (state) => state.values);
useEffect(() => {
saveToStorage(tab, values);
}, [tab, values]);
const reset = useCallback(() => {
form.reset({ ...emptyValues }, { keepDefaultValues: true });
}, [emptyValues, form]);
return {
form,
values,
reset,
activeFilterCount: countActiveFilters(values),
};
}
export function useBrandLookupFilters() {
const [showFilters, setShowFilters] = useState(false);
const pages = useTabFilters<TopPagesFilterValues>(
"pages",
EMPTY_TOP_PAGES_FILTERS,
);
const queries = useTabFilters<QueriesFilterValues>(
"queries",
EMPTY_QUERIES_FILTERS,
);
return {
pages,
queries,
showFilters,
setShowFilters,
};
}
export type BrandLookupFiltersState = ReturnType<typeof useBrandLookupFilters>;

View File

@ -1,5 +1,11 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
dateNullsLast,
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { BacklinksSourceLink } from "./BacklinksPageLinks"; import { BacklinksSourceLink } from "./BacklinksPageLinks";
import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes"; import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes";
@ -65,45 +71,6 @@ function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
); );
} }
function SortableHeader({
column,
label,
helpText,
align,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
};
label: string;
helpText: string;
align?: "left" | "right";
}) {
const sorted = column.getIsSorted();
const content = (
<button
type="button"
className="inline-flex items-center gap-1 font-medium transition-colors hover:text-base-content"
onClick={column.getToggleSortingHandler()}
aria-label={`Sort by ${label}`}
aria-pressed={!!sorted}
>
<HeaderHelpLabel label={label} helpText={helpText} />
{sorted === "asc" ? (
<ArrowUp className="size-3 shrink-0" />
) : sorted === "desc" ? (
<ArrowDown className="size-3 shrink-0" />
) : null}
</button>
);
if (align === "right") {
return <span className="flex w-full justify-end">{content}</span>;
}
return content;
}
export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
{ {
id: "source", id: "source",
@ -148,7 +115,7 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
</div> </div>
); );
}, },
sortingFn: "alphanumeric", sortingFn: stringNullsLast,
}, },
{ {
id: "target", id: "target",
@ -157,7 +124,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
), ),
size: 220, size: 220,
minSize: 150, minSize: 150,
accessorFn: (row) => row.targetCount,
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
if (row.depth > 0) { if (row.depth > 0) {
@ -175,20 +141,15 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
return null; return null;
}, },
sortingFn: "basic",
}, },
{ {
id: "anchor", id: "anchor",
header: ({ column }) => ( header: () => (
<SortableHeader <HeaderHelpLabel label="Anchor" helpText="Text or format of the link" />
column={column}
label="Anchor"
helpText="Text or format of the link"
/>
), ),
size: 150, size: 150,
minSize: 100, minSize: 100,
accessorFn: (row) => row._backlink?.anchor ?? row.domain, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
if (row.depth > 0) { if (row.depth > 0) {
const child = row.original._backlink; const child = row.original._backlink;
@ -206,8 +167,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
return null; return null;
}, },
sortingFn: "alphanumeric",
enableSorting: false,
}, },
{ {
id: "flags", id: "flags",
@ -236,17 +195,17 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
}, },
{ {
id: "linkAuthority", id: "linkAuthority",
header: ({ column }) => ( header: () => (
<SortableHeader <span className="flex w-full justify-end">
column={column} <HeaderHelpLabel
label="Link" label="Link"
helpText="Authority of the linking page" helpText="Authority of the linking page"
align="right"
/> />
</span>
), ),
size: 70, size: 70,
minSize: 50, minSize: 50,
accessorFn: (row) => row._backlink?.rank ?? null, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
if (row.depth > 0) { if (row.depth > 0) {
const child = row.original._backlink; const child = row.original._backlink;
@ -267,8 +226,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
return null; return null;
}, },
sortingFn: "basic",
enableSorting: false,
}, },
{ {
id: "domainAuthority", id: "domainAuthority",
@ -292,7 +249,8 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
</div> </div>
); );
}, },
sortingFn: "basic", sortingFn: numericNullsLast,
sortDescFirst: true,
}, },
{ {
id: "spamScore", id: "spamScore",
@ -319,7 +277,8 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
</div> </div>
); );
}, },
sortingFn: "basic", sortingFn: numericNullsLast,
sortDescFirst: true,
}, },
{ {
id: "firstSeen", id: "firstSeen",
@ -354,6 +313,7 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
</div> </div>
); );
}, },
sortingFn: "datetime", sortingFn: dateNullsLast,
sortDescFirst: true,
}, },
]; ];

View File

@ -1,182 +0,0 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import {
getNextSort,
type ReferringDomainsTableSort,
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
export function ReferringDomainsTableHeader({
sort,
onSortChange,
}: {
sort: ReferringDomainsTableSort;
onSortChange: (sort: ReferringDomainsTableSort) => void;
}) {
return (
<thead>
<tr>
<SortableHeaderCell
label="Domain"
helpText="The referring site linking to your target."
field="domain"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Backlinks"
helpText="Total backlinks found from this domain."
field="backlinks"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Referring Pages"
helpText="Unique pages on this domain that link to your target."
field="referringPages"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Rank"
helpText="Authority score for the referring domain."
field="rank"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Spam"
helpText="Spam risk score for this referring domain."
field="spamScore"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="First Seen"
helpText="When this domain was first discovered linking to your target."
field="firstSeen"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Issues"
helpText="Broken link and broken page counts tied to this domain."
field="issues"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
</tr>
</thead>
);
}
export function TopPagesTableHeader({
sort,
onSortChange,
}: {
sort: TopPagesTableSort;
onSortChange: (sort: TopPagesTableSort) => void;
}) {
return (
<thead>
<tr>
<SortableHeaderCell
label="Page"
helpText="Page on the target site receiving backlinks."
field="page"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Backlinks"
helpText="Total backlinks pointing to this page."
field="backlinks"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Referring Domains"
helpText="Unique domains linking to this page."
field="referringDomains"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Rank"
helpText="Authority score for this target page."
field="rank"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Broken Backlinks"
helpText="Backlinks pointing here that are currently broken."
field="brokenBacklinks"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
</tr>
</thead>
);
}
function SortableHeaderCell<TField extends string>({
align,
label,
helpText,
field,
defaultDirection,
sort,
onSortChange,
}: {
align?: "left" | "right";
label: string;
helpText: string;
field: TField;
defaultDirection: SortDirection;
sort: { field: TField; direction: SortDirection };
onSortChange: (sort: { field: TField; direction: SortDirection }) => void;
}) {
const isActive = sort.field === field;
const content = (
<button
type="button"
className="inline-flex items-center gap-1 font-medium transition-colors hover:text-base-content"
onClick={() => onSortChange(getNextSort(sort, field, defaultDirection))}
aria-label={`Sort by ${label}`}
aria-pressed={isActive}
>
<HeaderHelpLabel label={label} helpText={helpText} />
{isActive ? (
sort.direction === "asc" ? (
<ArrowUp className="size-3 shrink-0" />
) : (
<ArrowDown className="size-3 shrink-0" />
)
) : null}
</button>
);
return (
<th className={align === "right" ? "text-right" : undefined}>
{align === "right" ? (
<span className="inline-flex justify-end">{content}</span>
) : (
content
)}
</th>
);
}

View File

@ -1,27 +1,163 @@
import { useMemo, useState } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { ReferringDomainsTableHeader } from "./BacklinksTableHeaders";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { import {
DEFAULT_REFERRING_DOMAINS_SORT, createColumnHelper,
sortReferringDomainRows, flexRender,
} from "./backlinksTableSorting"; getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingFn,
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
compareNumericNullsLast,
dateNullsLast,
isDescending,
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { import {
formatCompactDate, formatCompactDate,
formatDecimal, formatDecimal,
formatNumber, formatNumber,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
const columnHelper = createColumnHelper<ReferringDomainRow>();
// Nulls always to the bottom in both directions, same as the pre-TanStack
// implementation. Secondary compare on brokenPages must also keep nulls last —
// coercing to 0 would mix unknown values with real zeroes.
const sortByIssues: SortingFn<ReferringDomainRow> = (left, right, columnId) => {
const descending = isDescending(left, columnId);
const primary = compareNumericNullsLast(
left.original.brokenBacklinks,
right.original.brokenBacklinks,
descending,
);
if (primary !== 0) return primary;
return compareNumericNullsLast(
left.original.brokenPages,
right.original.brokenPages,
descending,
);
};
const columns = [
columnHelper.accessor("domain", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Domain"
helpText="The referring site linking to your target."
/>
),
cell: ({ getValue }) => getValue() ?? "-",
sortingFn: stringNullsLast,
}),
columnHelper.accessor("backlinks", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Backlinks"
helpText="Total backlinks found from this domain."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("referringPages", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Referring Pages"
helpText="Unique pages on this domain that link to your target."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("rank", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Rank"
helpText="Authority score for the referring domain."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("spamScore", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Spam"
helpText="Spam risk score for this referring domain."
/>
),
cell: ({ getValue }) => formatDecimal(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("firstSeen", {
header: ({ column }) => (
<SortableHeader
column={column}
label="First Seen"
helpText="When this domain was first discovered linking to your target."
/>
),
cell: ({ getValue }) => formatCompactDate(getValue()),
sortingFn: dateNullsLast,
sortDescFirst: true,
}),
columnHelper.display({
id: "issues",
header: ({ column }) => (
<SortableHeader
column={column}
label="Issues"
helpText="Broken link and broken page counts tied to this domain."
/>
),
cell: ({ row }) => (
<div className="text-sm">
<div>Broken links: {formatNumber(row.original.brokenBacklinks)}</div>
<div className="text-base-content/55">
Broken pages: {formatNumber(row.original.brokenPages)}
</div>
</div>
),
enableSorting: true,
sortingFn: sortByIssues,
sortDescFirst: true,
}),
];
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
export function ReferringDomainsTable({ export function ReferringDomainsTable({
rows, rows,
}: { }: {
rows: BacklinksOverviewData["referringDomains"]; rows: BacklinksOverviewData["referringDomains"];
}) { }) {
const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT); const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const sortedRows = useMemo(
() => sortReferringDomainRows(rows, sort), const table = useReactTable({
[rows, sort], data: rows,
); columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (rows.length === 0) { if (rows.length === 0) {
return <EmptyTableState label="No referring domains match this filter." />; return <EmptyTableState label="No referring domains match this filter." />;
@ -30,24 +166,37 @@ export function ReferringDomainsTable({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="table table-sm"> <table className="table table-sm">
<ReferringDomainsTableHeader sort={sort} onSortChange={setSort} /> <thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody> <tbody>
{sortedRows.map((row, index) => ( {table.getRowModel().rows.map((row) => (
<tr key={`${row.domain ?? "domain"}-${index}`}> <tr key={row.id}>
<td className="font-medium break-all">{row.domain ?? "-"}</td> {row.getVisibleCells().map((cell) => (
<td>{formatNumber(row.backlinks)}</td> <td
<td>{formatNumber(row.referringPages)}</td> key={cell.id}
<td>{formatNumber(row.rank)}</td> className={
<td>{formatDecimal(row.spamScore)}</td> cell.column.id === "domain"
<td>{formatCompactDate(row.firstSeen)}</td> ? "font-medium break-all"
<td> : undefined
<div className="text-sm"> }
<div>Broken links: {formatNumber(row.brokenBacklinks)}</div> >
<div className="text-base-content/55"> {flexRender(cell.column.columnDef.cell, cell.getContext())}
Broken pages: {formatNumber(row.brokenPages)}
</div>
</div>
</td> </td>
))}
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -1,21 +1,116 @@
import { useMemo, useState } from "react"; import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { BacklinksExternalLink } from "./BacklinksPageLinks"; import { BacklinksExternalLink } from "./BacklinksPageLinks";
import { TopPagesTableHeader } from "./BacklinksTableHeaders";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
DEFAULT_TOP_PAGES_SORT,
sortTopPageRows,
} from "./backlinksTableSorting";
import { formatNumber } from "./backlinksPageUtils"; import { formatNumber } from "./backlinksPageUtils";
type TopPageRow = BacklinksOverviewData["topPages"][number];
const columnHelper = createColumnHelper<TopPageRow>();
const columns = [
columnHelper.accessor("page", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Page"
helpText="Page on the target site receiving backlinks."
/>
),
cell: ({ getValue }) => {
const page = getValue();
return page ? (
<BacklinksExternalLink
url={page}
label={page}
className="link link-hover break-all inline-flex items-center gap-1"
/>
) : (
"-"
);
},
sortingFn: stringNullsLast,
}),
columnHelper.accessor("backlinks", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Backlinks"
helpText="Total backlinks pointing to this page."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("referringDomains", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Referring Domains"
helpText="Unique domains linking to this page."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("rank", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Rank"
helpText="Authority score for this target page."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("brokenBacklinks", {
header: ({ column }) => (
<SortableHeader
column={column}
label="Broken Backlinks"
helpText="Backlinks pointing here that are currently broken."
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
];
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
export function TopPagesTable({ export function TopPagesTable({
rows, rows,
}: { }: {
rows: BacklinksOverviewData["topPages"]; rows: BacklinksOverviewData["topPages"];
}) { }) {
const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT); const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]);
const table = useReactTable({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (rows.length === 0) { if (rows.length === 0) {
return <EmptyTableState label="No top pages match this filter." />; return <EmptyTableState label="No top pages match this filter." />;
@ -24,25 +119,33 @@ export function TopPagesTable({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="table table-sm"> <table className="table table-sm">
<TopPagesTableHeader sort={sort} onSortChange={setSort} /> <thead>
<tbody> {table.getHeaderGroups().map((headerGroup) => (
{sortedRows.map((row, index) => ( <tr key={headerGroup.id}>
<tr key={`${row.page ?? "page"}-${index}`}> {headerGroup.headers.map((header) => (
<td className="min-w-80"> <th key={header.id}>
{row.page ? ( {header.isPlaceholder
<BacklinksExternalLink ? null
url={row.page} : flexRender(
label={row.page} header.column.columnDef.header,
className="link link-hover break-all inline-flex items-center gap-1" header.getContext(),
/>
) : (
"-"
)} )}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cell.column.id === "page" ? "min-w-80" : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
<td>{formatNumber(row.backlinks)}</td> ))}
<td>{formatNumber(row.referringDomains)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatNumber(row.brokenBacklinks)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -12,8 +12,10 @@ function passesNumericFilter(
max: string, max: string,
): boolean { ): boolean {
if (value == null) return true; if (value == null) return true;
if (min && value < Number(min)) return false; const minN = Number(min);
if (max && value > Number(max)) return false; if (min && !Number.isNaN(minN) && value < minN) return false;
const maxN = Number(max);
if (max && !Number.isNaN(maxN) && value > maxN) return false;
return true; return true;
} }

View File

@ -1,167 +0,0 @@
import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc";
export type ReferringDomainsTableSortField =
| "domain"
| "backlinks"
| "referringPages"
| "rank"
| "spamScore"
| "firstSeen"
| "issues";
export type TopPagesTableSortField =
| "page"
| "backlinks"
| "referringDomains"
| "rank"
| "brokenBacklinks";
export type TableSort<TField extends string> = {
field: TField;
direction: SortDirection;
};
export type ReferringDomainsTableSort =
TableSort<ReferringDomainsTableSortField>;
export type TopPagesTableSort = TableSort<TopPagesTableSortField>;
export const DEFAULT_REFERRING_DOMAINS_SORT: ReferringDomainsTableSort = {
field: "backlinks",
direction: "desc",
};
export const DEFAULT_TOP_PAGES_SORT: TopPagesTableSort = {
field: "backlinks",
direction: "desc",
};
export function getNextSort<TField extends string>(
current: TableSort<TField>,
field: TField,
defaultDirection: SortDirection,
): TableSort<TField> {
if (current.field !== field) {
return { field, direction: defaultDirection };
}
return {
field,
direction: current.direction === "asc" ? "desc" : "asc",
};
}
export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,
) {
return rows.toSorted((left, right) => {
switch (sort.field) {
case "domain":
return compareStrings(left.domain, right.domain, sort.direction);
case "backlinks":
return compareNumbers(left.backlinks, right.backlinks, sort.direction);
case "referringPages":
return compareNumbers(
left.referringPages,
right.referringPages,
sort.direction,
);
case "rank":
return compareNumbers(left.rank, right.rank, sort.direction);
case "spamScore":
return compareNumbers(left.spamScore, right.spamScore, sort.direction);
case "firstSeen":
return compareDates(left.firstSeen, right.firstSeen, sort.direction);
case "issues":
return compareIssues(left, right, sort.direction);
default:
return 0;
}
});
}
export function sortTopPageRows(
rows: BacklinksOverviewData["topPages"],
sort: TopPagesTableSort,
) {
return rows.toSorted((left, right) => {
switch (sort.field) {
case "page":
return compareStrings(left.page, right.page, sort.direction);
case "backlinks":
return compareNumbers(left.backlinks, right.backlinks, sort.direction);
case "referringDomains":
return compareNumbers(
left.referringDomains,
right.referringDomains,
sort.direction,
);
case "rank":
return compareNumbers(left.rank, right.rank, sort.direction);
case "brokenBacklinks":
return compareNumbers(
left.brokenBacklinks,
right.brokenBacklinks,
sort.direction,
);
default:
return 0;
}
});
}
function compareNumbers(
left: number | null | undefined,
right: number | null | undefined,
direction: SortDirection,
) {
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return direction === "asc" ? left - right : right - left;
}
function compareStrings(
left: string | null | undefined,
right: string | null | undefined,
direction: SortDirection,
) {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
const result = left.toLowerCase().localeCompare(right.toLowerCase());
return direction === "asc" ? result : -result;
}
function compareDates(
left: string | null | undefined,
right: string | null | undefined,
direction: SortDirection,
) {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
const leftValue = Date.parse(left);
const rightValue = Date.parse(right);
return direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
}
function compareIssues(
left: BacklinksOverviewData["referringDomains"][number],
right: BacklinksOverviewData["referringDomains"][number],
direction: SortDirection,
) {
const backlinkComparison = compareNumbers(
left.brokenBacklinks,
right.brokenBacklinks,
direction,
);
if (backlinkComparison !== 0) {
return backlinkComparison;
}
return compareNumbers(left.brokenPages, right.brokenPages, direction);
}

View File

@ -25,7 +25,7 @@ export function DomainSearchCard({
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4"> <div className="card-body gap-4">
<form <form
className="grid grid-cols-1 gap-3 lg:grid-cols-12" className="flex flex-col gap-3 lg:flex-row lg:items-center"
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<controlsForm.Field name="domain"> <controlsForm.Field name="domain">
@ -34,10 +34,11 @@ export function DomainSearchCard({
return ( return (
<label <label
className={`input input-bordered lg:col-span-6 flex items-center gap-2 ${domainError ? "input-error" : ""}`} className={`input input-bordered flex flex-1 items-center gap-2 ${domainError ? "input-error" : ""}`}
> >
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
<input <input
className="grow"
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)" placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
@ -54,7 +55,7 @@ export function DomainSearchCard({
<controlsForm.Field name="locationCode"> <controlsForm.Field name="locationCode">
{(field) => ( {(field) => (
<select <select
className="select select-bordered lg:col-span-2" className="select select-bordered shrink-0"
value={field.state.value} value={field.state.value}
onChange={(event) => { onChange={(event) => {
const next = Number(event.target.value); const next = Number(event.target.value);
@ -74,7 +75,7 @@ export function DomainSearchCard({
<controlsForm.Field name="sort"> <controlsForm.Field name="sort">
{(field) => ( {(field) => (
<select <select
className="select select-bordered lg:col-span-2" className="select select-bordered shrink-0"
value={field.state.value} value={field.state.value}
onChange={(event) => { onChange={(event) => {
const next = toSortMode(event.target.value) ?? "rank"; const next = toSortMode(event.target.value) ?? "rank";
@ -95,7 +96,7 @@ export function DomainSearchCard({
{(isSubmitting) => ( {(isSubmitting) => (
<button <button
type="submit" type="submit"
className="btn btn-primary lg:col-span-2" className="btn btn-primary shrink-0 px-6"
disabled={isLoading || isSubmitting} disabled={isLoading || isSubmitting}
> >
{isLoading || isSubmitting ? "Loading..." : "Search"} {isLoading || isSubmitting ? "Loading..." : "Search"}

View File

@ -0,0 +1,20 @@
import { z } from "zod";
import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory";
const brandLookupSearchBodySchema = z.object({
query: z.string(),
});
type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>;
export type BrandLookupSearchHistoryItem = BrandLookupSearchBody & {
timestamp: number;
};
export function useBrandLookupSearchHistory(projectId: string) {
return useTimestampedSearchHistory({
storageKey: `brand-lookup-search-history:${projectId}`,
bodySchema: brandLookupSearchBodySchema,
isSame: (a, b) => a.query === b.query,
});
}

View File

@ -0,0 +1,48 @@
import { z } from "zod";
import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory";
import {
promptExplorerModelSchema,
webSearchCountryCodeSchema,
} from "@/types/schemas/ai-search";
const promptExplorerSearchBodySchema = z.object({
prompt: z.string(),
highlightBrand: z.string(),
models: z.array(promptExplorerModelSchema),
webSearch: z.boolean(),
webSearchCountryCode: webSearchCountryCodeSchema,
});
type PromptExplorerSearchBody = z.infer<typeof promptExplorerSearchBodySchema>;
export type PromptExplorerSearchHistoryItem = PromptExplorerSearchBody & {
timestamp: number;
};
function sameModels(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = a.toSorted();
const sortedB = b.toSorted();
return sortedA.every((model, index) => model === sortedB[index]);
}
function isSameSearch(
a: PromptExplorerSearchBody,
b: PromptExplorerSearchBody,
): boolean {
return (
a.prompt === b.prompt &&
a.highlightBrand === b.highlightBrand &&
a.webSearch === b.webSearch &&
a.webSearchCountryCode === b.webSearchCountryCode &&
sameModels(a.models, b.models)
);
}
export function usePromptExplorerSearchHistory(projectId: string) {
return useTimestampedSearchHistory({
storageKey: `prompt-explorer-search-history:${projectId}`,
bodySchema: promptExplorerSearchBodySchema,
isSame: isSameSearch,
});
}

View File

@ -0,0 +1,46 @@
import { z } from "zod";
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
import { jsonCodec } from "@/shared/json";
/**
* Shared recent-searches hook: localStorage-backed list of items tagged with
* `timestamp`, keyed by a per-project storage key. Each call site provides
* the item body's Zod schema (timestamp is added automatically) and a dedupe
* predicate that decides whether two searches are "the same".
*/
const timestampFieldSchema = z.object({ timestamp: z.number() });
export function useTimestampedSearchHistory<TBody extends object>(args: {
storageKey: string;
bodySchema: z.ZodType<TBody>;
isSame: (existing: TBody, next: TBody) => boolean;
maxItems?: number;
}) {
type StoredItem = TBody & { timestamp: number };
const codec = jsonCodec(
z.array(z.intersection(args.bodySchema, timestampFieldSchema)),
);
const { history, isLoaded, addItem, removeItem } = useLocalHistoryStore<
StoredItem,
TBody
>({
storageKey: args.storageKey,
maxItems: args.maxItems ?? 20,
parse: (raw) => {
const parsed = codec.safeParse(raw);
return parsed.success ? parsed.data : null;
},
isSameItem: (existing, next) => args.isSame(existing, next),
createItem: (input) => ({ ...input, timestamp: Date.now() }),
getItemKey: (item) => item.timestamp,
});
return {
history,
isLoaded,
addSearch: addItem,
removeHistoryItem: removeItem,
};
}

View File

@ -205,7 +205,7 @@ function TopNav({
); );
return ( return (
<div key={entry.label} className="dropdown"> <div key={entry.label} className="dropdown dropdown-hover">
<button <button
type="button" type="button"
tabIndex={0} tabIndex={0}
@ -221,7 +221,7 @@ function TopNav({
</button> </button>
<ul <ul
tabIndex={0} tabIndex={0}
className="dropdown-content z-20 menu mt-1 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg" className="dropdown-content z-20 menu w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
> >
{entry.items.map((item) => { {entry.items.map((item) => {
const { icon: Icon, matchSegment, ...linkProps } = item; const { icon: Icon, matchSegment, ...linkProps } = item;

View File

@ -4,7 +4,9 @@ import {
ClipboardCheck, ClipboardCheck,
Globe, Globe,
Link2, Link2,
MessageSquare,
Search, Search,
Sparkles,
TrendingUp, TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { linkOptions } from "@tanstack/react-router"; import { linkOptions } from "@tanstack/react-router";
@ -46,9 +48,21 @@ const projectNavItems = [
icon: ClipboardCheck, icon: ClipboardCheck,
matchSegment: "/audit", matchSegment: "/audit",
}, },
{
to: "/p/$projectId/brand-lookup" as const,
label: "Brand Lookup",
icon: Sparkles,
matchSegment: "/brand-lookup",
},
{
to: "/p/$projectId/prompt-explorer" as const,
label: "Prompt Explorer",
icon: MessageSquare,
matchSegment: "/prompt-explorer",
},
{ {
to: "/p/$projectId/ai" as const, to: "/p/$projectId/ai" as const,
label: "AI", label: "AI & Agents",
icon: Bot, icon: Bot,
matchSegment: "/ai", matchSegment: "/ai",
}, },
@ -91,6 +105,13 @@ export function getProjectNavGroups(projectId: string) {
bySegment("/audit"), bySegment("/audit"),
], ],
}, },
{
type: "group" as const,
label: "AI Visibility",
icon: Sparkles,
matchSegments: ["/brand-lookup", "/prompt-explorer"],
items: [bySegment("/brand-lookup"), bySegment("/prompt-explorer")],
},
{ {
type: "standalone" as const, type: "standalone" as const,
item: bySegment("/ai"), item: bySegment("/ai"),

View File

@ -108,7 +108,9 @@ select {
/* Global input focus styling - use primary color instead of default ring */ /* Global input focus styling - use primary color instead of default ring */
.input:focus, .input:focus,
.input:focus-visible { .input:focus-visible,
.textarea:focus,
.textarea:focus-visible {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent); background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);

View File

@ -29,8 +29,10 @@ import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords' import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain' import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks' import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit' import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai' import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
@ -136,6 +138,12 @@ const ProjectPProjectIdRankTrackingRoute =
path: '/rank-tracking', path: '/rank-tracking',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const ProjectPProjectIdPromptExplorerRoute =
ProjectPProjectIdPromptExplorerRouteImport.update({
id: '/prompt-explorer',
path: '/prompt-explorer',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdKeywordsRoute = const ProjectPProjectIdKeywordsRoute =
ProjectPProjectIdKeywordsRouteImport.update({ ProjectPProjectIdKeywordsRouteImport.update({
id: '/keywords', id: '/keywords',
@ -147,6 +155,12 @@ const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
path: '/domain', path: '/domain',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const ProjectPProjectIdBrandLookupRoute =
ProjectPProjectIdBrandLookupRouteImport.update({
id: '/brand-lookup',
path: '/brand-lookup',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdBacklinksRoute = const ProjectPProjectIdBacklinksRoute =
ProjectPProjectIdBacklinksRouteImport.update({ ProjectPProjectIdBacklinksRouteImport.update({
id: '/backlinks', id: '/backlinks',
@ -205,8 +219,10 @@ export interface FileRoutesByFullPath {
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute
@ -230,8 +246,10 @@ export interface FileRoutesByTo {
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@ -261,8 +279,10 @@ export interface FileRoutesById {
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
@ -290,8 +310,10 @@ export interface FileRouteTypes {
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking' | '/p/$projectId/rank-tracking'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/' | '/p/$projectId/'
@ -315,8 +337,10 @@ export interface FileRouteTypes {
| '/api/autumn/$' | '/api/autumn/$'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId' | '/p/$projectId'
| '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/rank-tracking/$configId'
@ -345,8 +369,10 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/ai' | '/_project/p/$projectId/ai'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/brand-lookup'
| '/_project/p/$projectId/domain' | '/_project/p/$projectId/domain'
| '/_project/p/$projectId/keywords' | '/_project/p/$projectId/keywords'
| '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved' | '/_project/p/$projectId/saved'
| '/_project/p/$projectId/' | '/_project/p/$projectId/'
@ -510,6 +536,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute parentRoute: typeof ProjectPProjectIdRouteRoute
} }
'/_project/p/$projectId/prompt-explorer': {
id: '/_project/p/$projectId/prompt-explorer'
path: '/prompt-explorer'
fullPath: '/p/$projectId/prompt-explorer'
preLoaderRoute: typeof ProjectPProjectIdPromptExplorerRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/keywords': { '/_project/p/$projectId/keywords': {
id: '/_project/p/$projectId/keywords' id: '/_project/p/$projectId/keywords'
path: '/keywords' path: '/keywords'
@ -524,6 +557,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdDomainRouteImport preLoaderRoute: typeof ProjectPProjectIdDomainRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute parentRoute: typeof ProjectPProjectIdRouteRoute
} }
'/_project/p/$projectId/brand-lookup': {
id: '/_project/p/$projectId/brand-lookup'
path: '/brand-lookup'
fullPath: '/p/$projectId/brand-lookup'
preLoaderRoute: typeof ProjectPProjectIdBrandLookupRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/backlinks': { '/_project/p/$projectId/backlinks': {
id: '/_project/p/$projectId/backlinks' id: '/_project/p/$projectId/backlinks'
path: '/backlinks' path: '/backlinks'
@ -633,8 +673,10 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdAiRoute: typeof ProjectPProjectIdAiRoute ProjectPProjectIdAiRoute: typeof ProjectPProjectIdAiRoute
ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
@ -645,8 +687,10 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdAiRoute: ProjectPProjectIdAiRoute, ProjectPProjectIdAiRoute: ProjectPProjectIdAiRoute,
ProjectPProjectIdAuditRoute: ProjectPProjectIdAuditRouteWithChildren, ProjectPProjectIdAuditRoute: ProjectPProjectIdAuditRouteWithChildren,
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute, ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute, ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute, ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren, ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute, ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,

View File

@ -0,0 +1,30 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { BrandLookupPage } from "@/client/features/ai-search/BrandLookupPage";
import { brandLookupSearchSchema } from "@/types/schemas/ai-search";
export const Route = createFileRoute("/_project/p/$projectId/brand-lookup")({
validateSearch: brandLookupSearchSchema,
component: BrandLookupRoute,
});
function BrandLookupRoute() {
const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
const { q = "" } = Route.useSearch();
return (
<BrandLookupPage
projectId={projectId}
initialQuery={q}
onQueryChange={(nextQuery) => {
void navigate({
search: (prev) => ({
...prev,
q: nextQuery.trim() || undefined,
}),
replace: true,
});
}}
/>
);
}

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { PromptExplorerPage } from "@/client/features/ai-search/PromptExplorerPage";
export const Route = createFileRoute("/_project/p/$projectId/prompt-explorer")({
component: PromptExplorerRoute,
});
function PromptExplorerRoute() {
const { projectId } = Route.useParams();
return <PromptExplorerPage projectId={projectId} />;
}

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { safeHostname, safeHttpUrl } from "./safeUrl";
describe("safeHttpUrl", () => {
it.each([
"https://example.com",
"http://example.com",
"https://example.com/path?q=1#frag",
"https://sub.example.io",
])("accepts %s", (input) => {
expect(safeHttpUrl(input)).toBe(input);
});
it.each([
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"ftp://example.com",
"not a url",
"",
"https://user:pass@evil.example.com",
"https://user@evil.example.com",
])("rejects %s", (input) => {
expect(safeHttpUrl(input)).toBeNull();
});
it("returns null for null/undefined", () => {
expect(safeHttpUrl(null)).toBeNull();
expect(safeHttpUrl(undefined)).toBeNull();
});
});
describe("safeHostname", () => {
it("strips protocol and www prefix", () => {
expect(safeHostname("https://www.example.com/path")).toBe("example.com");
expect(safeHostname("http://sub.example.io")).toBe("sub.example.io");
});
it("returns null for unsafe schemes", () => {
expect(safeHostname("javascript:alert(1)")).toBeNull();
expect(safeHostname("data:text/html,foo")).toBeNull();
});
it("returns null for invalid input", () => {
expect(safeHostname("not a url")).toBeNull();
expect(safeHostname(null)).toBeNull();
});
});

View File

@ -0,0 +1,39 @@
/**
* Validate a URL string against an http(s) scheme allow-list.
*
* AI Search renders citation and top-page URLs as `<a href>` in the UI. The
* URLs come from either DataForSEO (mostly safe but still external) or LLM
* responses (untrusted a crafted prompt can coax a model into emitting
* `javascript:`/`data:` payloads). Without this filter, those links are
* clickable from inside an authenticated session.
*
* Returns the URL string unchanged if its protocol is `http:` or `https:`,
* otherwise null. Callers should drop null entries before rendering.
*/
export function safeHttpUrl(value: string | null | undefined): string | null {
if (typeof value !== "string" || value.length === 0) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
// Reject `user:pass@host` — the hostname the user sees in link text may
// differ from where the browser authenticates.
if (url.username || url.password) return null;
return value;
} catch {
return null;
}
}
/**
* Extract the bare hostname (without leading `www.`) from a URL string,
* returning null if the URL is invalid or uses a non-http(s) scheme.
*/
export function safeHostname(value: string | null | undefined): string | null {
const url = safeHttpUrl(value);
if (!url) return null;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return null;
}
}

View File

@ -0,0 +1,413 @@
import { waitUntil } from "cloudflare:workers";
import { sortBy } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import {
buildLlmTarget,
CHATGPT_LANGUAGE_CODE,
CHATGPT_LOCATION_CODE,
type LlmPlatform,
} from "@/server/lib/dataforseoLlm";
import type {
LlmAggregatedTotal,
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import { AppError } from "@/server/lib/errors";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl";
import {
brandLookupResultSchema,
type BrandLookupInput,
type BrandLookupResult,
} from "@/types/schemas/ai-search";
import { detectTarget } from "@/server/features/ai-search/targetDetection";
/**
* Brand Lookup is the AI-search analog of Domain Overview. The user types a
* brand name or domain; we hit DataForSEO's LLM Mentions API across ChatGPT
* (US-only) and Google AI Overview, then shape the response into something
* the UI can render directly. Stateless no DB writes, R2 caching only.
*/
/** Brand lookup data refreshes daily; underlying API is updated monthly. */
const BRAND_LOOKUP_TTL_SECONDS = 24 * 60 * 60;
const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"];
const TOP_PAGES_PER_PLATFORM = 10;
const TOP_QUERIES_PER_PLATFORM = 25;
export async function getBrandLookup(
input: BrandLookupInput,
billingCustomer: BillingCustomerContext,
): Promise<BrandLookupResult> {
const detected = detectTarget(input.query);
const cacheKey = await buildCacheKey("ai-search:brand-lookup", {
organizationId: billingCustomer.organizationId,
projectId: input.projectId,
targetType: detected.type,
targetValue: detected.value.toLowerCase(),
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey));
if (cached.success) return cached.data;
const dataforseo = createDataforseoClient(billingCustomer);
// Settle each platform independently so a failure in one doesn't discard
// the other (which the caller already paid for via meterDataforseoCall).
const settled = await Promise.allSettled(
PLATFORMS.map((platform) =>
fetchPlatformData(platform, detected, input, dataforseo),
),
);
// Credits exhaustion is global, not per-platform — re-throw immediately so
// the user gets a single clear "out of credits" error instead of a
// half-rendered result.
for (const settledResult of settled) {
if (
settledResult.status === "rejected" &&
settledResult.reason instanceof AppError &&
settledResult.reason.code === "INSUFFICIENT_CREDITS"
) {
throw settledResult.reason;
}
}
const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => {
const platform = PLATFORMS[i];
if (settledResult.status === "fulfilled") {
return { platform, status: "success", bundle: settledResult.value };
}
console.error(
`ai-search.brand-lookup.${platform}.error:`,
settledResult.reason,
);
return { platform, status: "error", bundle: null };
});
const result = shapeResult({
query: input.query,
detected,
platformBundles,
userLocationCode: input.locationCode,
userLanguageCode: input.languageCode,
});
// Only cache when every platform succeeded — otherwise users would see a
// stale partial result for 24h and have no way to retry without busting it.
const allSucceeded = platformBundles.every((b) => b.status === "success");
if (allSucceeded && result.hasData) {
waitUntil(
setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => {
console.error("ai-search.brand-lookup.cache-write failed:", err);
}),
);
}
return result;
}
type PlatformFetchInput = Pick<
BrandLookupInput,
"locationCode" | "languageCode"
>;
type PlatformBundle = {
aggregated: LlmAggregatedTotal;
topPages: LlmTopPagesItem[];
mentions: LlmMentionItem[];
};
type PlatformOutcome = {
platform: LlmPlatform;
status: "success" | "error";
bundle: PlatformBundle | null;
};
async function fetchPlatformData(
platform: LlmPlatform,
detected: ReturnType<typeof detectTarget>,
input: PlatformFetchInput,
dataforseo: ReturnType<typeof createDataforseoClient>,
): Promise<PlatformBundle> {
const target = buildLlmTarget({
type: detected.type,
value: detected.value,
});
// ChatGPT mentions DB only contains US/en data per DataForSEO docs.
const locationCode =
platform === "chat_gpt" ? CHATGPT_LOCATION_CODE : input.locationCode;
const languageCode =
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode;
// `allSettled` so one sub-call failing doesn't discard the other two we
// already paid for. Each sub-call is metered independently upstream.
const [aggregated, topPages, mentions] = await Promise.allSettled([
dataforseo.aiSearch.aggregatedMetrics({
target,
platform,
locationCode,
languageCode,
internalListLimit: 20,
}),
dataforseo.aiSearch.topPages({
target,
platform,
locationCode,
languageCode,
itemsListLimit: TOP_PAGES_PER_PLATFORM,
}),
dataforseo.aiSearch.mentionsSearch({
target,
platform,
locationCode,
languageCode,
limit: TOP_QUERIES_PER_PLATFORM,
}),
]);
rethrowIfCreditsExhausted(aggregated, topPages, mentions);
// If every sub-call failed we have nothing to render for this platform —
// reject so the outer `allSucceeded` gate refuses to cache a blank result.
const allRejected =
aggregated.status === "rejected" &&
topPages.status === "rejected" &&
mentions.status === "rejected";
if (allRejected) throw aggregated.reason;
return {
aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"),
topPages: fulfilledOr(topPages, () => [], platform, "topPages"),
mentions: fulfilledOr(mentions, () => [], platform, "mentions"),
};
}
function rethrowIfCreditsExhausted(
...results: Array<PromiseSettledResult<unknown>>
): void {
for (const result of results) {
if (
result.status === "rejected" &&
result.reason instanceof AppError &&
result.reason.code === "INSUFFICIENT_CREDITS"
) {
throw result.reason;
}
}
}
function fulfilledOr<T>(
result: PromiseSettledResult<T>,
fallback: () => T,
platform: LlmPlatform,
label: string,
): T {
if (result.status === "fulfilled") return result.value;
console.error(
`ai-search.brand-lookup.${platform}.${label}.error:`,
result.reason,
);
return fallback();
}
type ShapeArgs = {
query: string;
detected: ReturnType<typeof detectTarget>;
platformBundles: PlatformOutcome[];
userLocationCode: number;
userLanguageCode: string;
};
function shapeResult(args: ShapeArgs): BrandLookupResult {
const successfulBundles = args.platformBundles.filter(
(b): b is PlatformOutcome & { bundle: PlatformBundle } =>
b.status === "success" && b.bundle !== null,
);
// ChatGPT data is always fetched US/en (DataForSEO only indexes that
// locale), so when the user picks a non-US/en locale we must not fold its
// numbers into cross-platform totals or the monthly trend — doing so would
// mix two different datasets under one locale label. Per-platform rows
// still render ChatGPT separately with the "US-only" tooltip.
// Match the primary subtag so "en-US"/"en_US" still count as English.
const primaryLanguage = args.userLanguageCode.toLowerCase().split(/[-_]/)[0];
const chatGptLocaleMatches =
args.userLocationCode === CHATGPT_LOCATION_CODE &&
primaryLanguage === CHATGPT_LANGUAGE_CODE;
const perPlatform = args.platformBundles.map((outcome) => {
if (outcome.status === "error" || !outcome.bundle) {
return {
platform: outcome.platform,
status: "error" as const,
mentions: null,
aiSearchVolume: null,
impressions: null,
};
}
const platformGroup = outcome.bundle.aggregated.platform?.find(
(entry) => entry.key === outcome.platform,
);
return {
platform: outcome.platform,
status: "success" as const,
mentions: roundOrNull(platformGroup?.mentions),
aiSearchVolume: roundOrNull(platformGroup?.ai_search_volume),
impressions: roundOrNull(platformGroup?.impressions),
};
});
const aggregatablePlatforms = perPlatform.filter(
(p) => chatGptLocaleMatches || p.platform !== "chat_gpt",
);
const totalMentions = sumNullable(
aggregatablePlatforms.map((p) => p.mentions),
);
const totalAiSearchVolume = sumNullable(
aggregatablePlatforms.map((p) => p.aiSearchVolume),
);
const totalImpressions = sumNullable(
aggregatablePlatforms.map((p) => p.impressions),
);
const topPages = sortBy(
successfulBundles.flatMap((bundle) =>
bundle.bundle.topPages
.map((page) => {
const safeUrl = safeHttpUrl(page.key);
if (!safeUrl) return null;
return {
url: safeUrl,
domain: safeHostname(safeUrl),
mentions: roundOrNull(
page.platform?.find((entry) => entry.key === bundle.platform)
?.mentions,
),
platform: bundle.platform,
};
})
.filter((page): page is NonNullable<typeof page> => page !== null),
),
[(page) => page.mentions ?? 0, "desc"],
).slice(0, 20);
const topQueries = sortBy(
successfulBundles.flatMap((bundle) =>
bundle.bundle.mentions
.filter(
(item): item is LlmMentionItem & { question: string } =>
typeof item.question === "string" && item.question.length > 0,
)
.map((item) => ({
question: item.question,
platform: bundle.platform,
aiSearchVolume: roundOrNull(item.ai_search_volume),
firstSeenAt: item.first_response_at ?? null,
lastSeenAt: item.last_response_at ?? null,
citedSources: (item.sources ?? [])
.map((src) => {
const safeUrl = safeHttpUrl(src.url);
if (!safeUrl) return null;
return {
url: safeUrl,
domain: src.domain ?? safeHostname(safeUrl),
title: src.title ?? null,
};
})
.filter((src): src is NonNullable<typeof src> => src !== null)
.slice(0, 10),
brandsMentioned: (item.brand_entities ?? [])
.map((entity) => entity.title ?? "")
.filter((title) => title.length > 0)
.slice(0, 20),
})),
),
[(query) => query.aiSearchVolume ?? 0, "desc"],
).slice(0, 50);
const trendBundles = chatGptLocaleMatches
? successfulBundles
: successfulBundles.filter((b) => b.platform !== "chat_gpt");
const monthlyVolume = aggregateMonthlyVolume(trendBundles);
const hasData =
(totalMentions ?? 0) > 0 ||
topPages.length > 0 ||
topQueries.length > 0 ||
monthlyVolume.length > 0;
return {
query: args.query,
detectedTargetType: args.detected.type,
resolvedTarget: args.detected.value,
fetchedAt: new Date().toISOString(),
hasData,
totalMentions,
totalAiSearchVolume,
totalImpressions,
perPlatform,
topPages,
topQueries,
monthlyVolume,
};
}
function sumNullable(values: Array<number | null>): number | null {
let total = 0;
let hasValue = false;
for (const value of values) {
if (value != null) {
total += value;
hasValue = true;
}
}
return hasValue ? total : null;
}
function roundOrNull(value: number | null | undefined): number | null {
if (value == null) return null;
return Math.round(value);
}
/**
* Sum monthly mention volume across all returned mention items, regardless of
* platform. Returns the most recent 12 months in chronological order.
*/
function aggregateMonthlyVolume(
bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>,
): BrandLookupResult["monthlyVolume"] {
const totals = new Map<string, number>();
for (const outcome of bundles) {
for (const mention of outcome.bundle.mentions) {
for (const monthly of mention.monthly_searches ?? []) {
if (monthly.search_volume == null) continue;
const key = `${monthly.year}-${monthly.month}`;
totals.set(key, (totals.get(key) ?? 0) + monthly.search_volume);
}
}
}
const entries = Array.from(totals.entries()).map(([key, volume]) => {
const [yearStr, monthStr] = key.split("-");
return {
year: Number(yearStr),
month: Number(monthStr),
volume: Math.round(volume),
};
});
return sortBy(
entries,
[(entry) => entry.year, "asc"],
[(entry) => entry.month, "asc"],
).slice(-12);
}

View File

@ -0,0 +1,297 @@
import { waitUntil } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import type { LlmResponseResult } from "@/server/lib/dataforseoLlmSchemas";
import { AppError } from "@/server/lib/errors";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl";
import {
promptExplorerModelResultSchema,
type PromptExplorerCitation,
type PromptExplorerInput,
type PromptExplorerModel,
type PromptExplorerModelResult,
type PromptExplorerResult,
} from "@/types/schemas/ai-search";
/**
* Prompt Explorer asks one prompt across one-to-four LLM models and renders
* the answers side by side. Each (prompt, model) tuple is cached in R2 for 7
* days because LLM responses are expensive and reasonably stable over short
* windows.
*
* Per-model errors are isolated: a Claude API failure must not prevent
* ChatGPT/Gemini/Perplexity results from rendering. We use Promise.allSettled
* to enforce that.
*/
/** LLM responses are stable enough for a 7-day cache. */
const PROMPT_RESPONSE_TTL_SECONDS = 7 * 24 * 60 * 60;
/** Hard cap on response length to keep payloads sane. */
const PROMPT_RESPONSE_MAX_TOKENS = 1024;
type DataforseoClient = ReturnType<typeof createDataforseoClient>;
export async function explorePrompt(
input: PromptExplorerInput,
billingCustomer: BillingCustomerContext,
): Promise<PromptExplorerResult> {
const dataforseo = createDataforseoClient(billingCustomer);
const highlightBrand = input.highlightBrand?.trim() || null;
// Dedupe models so a request like ["claude","claude"] doesn't fan out to two
// paid upstream calls for the same answer.
const uniqueModels = Array.from(new Set(input.models));
const settled = await Promise.allSettled(
uniqueModels.map((model) =>
runModel({
model,
input,
highlightBrand,
billingCustomer,
dataforseo,
}),
),
);
const results: PromptExplorerModelResult[] = settled.map(
(settledResult, index) => {
const model = uniqueModels[index];
if (settledResult.status === "fulfilled") return settledResult.value;
return mapErrorToResult(model, settledResult.reason);
},
);
return {
prompt: input.prompt,
highlightBrand,
fetchedAt: new Date().toISOString(),
results,
};
}
type RunModelArgs = {
model: PromptExplorerModel;
input: PromptExplorerInput;
highlightBrand: string | null;
billingCustomer: BillingCustomerContext;
dataforseo: DataforseoClient;
};
async function runModel(
args: RunModelArgs,
): Promise<PromptExplorerModelResult> {
const cacheKey = await buildCacheKey("ai-search:prompt-response", {
organizationId: args.billingCustomer.organizationId,
projectId: args.input.projectId,
model: args.model,
// Collapse only whitespace differences. Casing is deliberately preserved:
// prompts like "Compare Go vs go" or case-sensitive code snippets must
// not collide with their lowercase twins.
prompt: normalizePromptForCache(args.input.prompt),
webSearch: args.input.webSearch,
webSearchCountryCode: args.input.webSearchCountryCode ?? null,
// Bumped when prompt/payload shape changes — busts stale cache entries.
systemPromptV: 4,
});
const cached = promptExplorerModelResultSchema.safeParse(
await getCached(cacheKey),
);
if (cached.success && cached.data.status === "success") {
// highlightBrand is not part of the cache key — re-apply it so the same
// cached response can power different brand highlights for free.
return reapplyHighlightBrand(cached.data, args.highlightBrand);
}
const rawResponse = await fetchModelResponse(args);
const shaped = shapeSuccess(args.model, rawResponse);
waitUntil(
setCached(cacheKey, shaped, PROMPT_RESPONSE_TTL_SECONDS).catch((err) => {
console.error("ai-search.prompt-response.cache-write failed:", err);
}),
);
return reapplyHighlightBrand(shaped, args.highlightBrand);
}
// DataForSEO's Claude catalog caps at the 4.0 family (no Sonnet 4.5+ yet).
const MODEL_NAMES: Record<PromptExplorerModel, string> = {
chat_gpt: "gpt-5",
claude: "claude-sonnet-4-0",
gemini: "gemini-2.5-pro",
perplexity: "sonar-reasoning-pro",
};
function fetchModelResponse(args: RunModelArgs): Promise<LlmResponseResult> {
return args.dataforseo.aiSearch.llmResponse({
modelSlug: args.model,
modelName: MODEL_NAMES[args.model],
userPrompt: args.input.prompt,
webSearch: args.input.webSearch,
webSearchCountryCode: args.input.webSearchCountryCode,
maxOutputTokens: PROMPT_RESPONSE_MAX_TOKENS,
});
}
/**
* Shape a raw LLM response into the brand-agnostic success payload we cache.
* Brand-specific fields (`matchedBrand`, `brandMentioned`) are computed
* separately by `reapplyHighlightBrand` on every read so one cache entry can
* serve requests with different `highlightBrand` values.
*/
function shapeSuccess(
model: PromptExplorerModel,
response: LlmResponseResult,
): PromptExplorerModelResult {
const text = extractText(response);
const citations = extractCitations(response);
const fanOutQueries = (response.fan_out_queries ?? []).slice(0, 20);
return {
status: "success" as const,
model,
modelName: response.model_name ?? null,
text,
citations,
fanOutQueries,
brandMentioned: null,
outputTokens:
response.output_tokens != null
? Math.round(response.output_tokens)
: null,
webSearch: response.web_search ?? false,
};
}
function reapplyHighlightBrand(
result: PromptExplorerModelResult,
highlightBrand: string | null,
): PromptExplorerModelResult {
if (result.status !== "success") return result;
const citations = result.citations.map((citation) => ({
...citation,
matchedBrand: matchesBrand(citation.url, citation.title, highlightBrand),
}));
return {
...result,
citations,
brandMentioned: computeBrandMentioned(
result.text,
citations,
highlightBrand,
),
};
}
function extractText(response: LlmResponseResult): string {
const textParts: string[] = [];
for (const item of response.items ?? []) {
if (item.type !== "message") continue;
for (const section of item.sections ?? []) {
if (typeof section.text === "string" && section.text.length > 0) {
textParts.push(section.text);
}
}
}
return textParts.join("\n\n").trim();
}
function extractCitations(
response: LlmResponseResult,
): PromptExplorerCitation[] {
const seen = new Set<string>();
const citations: PromptExplorerCitation[] = [];
for (const item of response.items ?? []) {
if (item.type !== "message") continue;
for (const section of item.sections ?? []) {
for (const annotation of section.annotations ?? []) {
if (annotation.type !== "citation") continue;
// Drop non-http(s) URLs — LLMs can be coaxed into emitting
// `javascript:` payloads as "citations" and we render these as
// <a href> in the UI.
const safeUrl = safeHttpUrl(annotation.url);
if (!safeUrl || seen.has(safeUrl)) continue;
seen.add(safeUrl);
citations.push({
url: safeUrl,
domain: safeHostname(safeUrl),
title: annotation.title ?? null,
matchedBrand: false,
});
}
}
}
return citations.slice(0, 25);
}
function computeBrandMentioned(
text: string,
citations: PromptExplorerCitation[],
highlightBrand: string | null,
): boolean | null {
if (!highlightBrand) return null;
if (citations.some((c) => c.matchedBrand)) return true;
return mentionRegex(highlightBrand).test(text);
}
function matchesBrand(
url: string,
title: string | null | undefined,
highlightBrand: string | null,
): boolean {
if (!highlightBrand) return false;
const needle = highlightBrand.toLowerCase();
const haystack = `${url} ${title ?? ""}`.toLowerCase();
return haystack.includes(needle);
}
function mentionRegex(brand: string): RegExp {
// Case-insensitive match on the brand string with word-boundary guards only
// on sides that end in a word char — otherwise \b fails for brands like
// "C++" or "AT&T" where the terminal char is non-word. When a boundary char
// is non-word we guard with a negative lookaround against that same char so
// "C++" doesn't match "C+++".
const escaped = brand.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const firstEscaped = brand[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const lastEscaped = brand[brand.length - 1].replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
const leading = /^\w/.test(brand) ? "\\b" : `(?<!${firstEscaped})`;
const trailing = /\w$/.test(brand) ? "\\b" : `(?!${lastEscaped})`;
return new RegExp(`${leading}${escaped}${trailing}`, "i");
}
function normalizePromptForCache(prompt: string): string {
return prompt.trim().replace(/\s+/g, " ");
}
function mapErrorToResult(
model: PromptExplorerModel,
reason: unknown,
): PromptExplorerModelResult {
if (reason instanceof AppError && reason.code === "INSUFFICIENT_CREDITS") {
// Re-throw INSUFFICIENT_CREDITS so the whole request surfaces it instead
// of silently degrading to "Claude failed" — credits exhaustion is global,
// not per-model.
throw reason;
}
// Log full upstream detail server-side; surface only a generic message to
// the client. Upstream error bodies sometimes echo request paths or
// diagnostic fields we don't want to leak to the browser.
console.error(`ai-search.prompt-response.${model}.error:`, reason);
return {
status: "error" as const,
model,
errorCode: "UPSTREAM_ERROR",
message: "This model is temporarily unavailable. Please try again.",
};
}

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { detectTarget } from "./targetDetection";
describe("detectTarget", () => {
it.each([
["opus.pro", "opus.pro"],
["https://opus.pro", "opus.pro"],
["https://www.opus.pro/features", "opus.pro"],
["WWW.Example.COM", "example.com"],
["sub.example.io", "sub.example.io"],
])("treats %s as a domain", (input, expected) => {
expect(detectTarget(input)).toEqual({ type: "domain", value: expected });
});
it.each([
"Opus Clip",
"best ai video clipper",
"OpenAI",
"GPT-5",
"ChatGPT pricing",
])("treats '%s' as a keyword", (input) => {
expect(detectTarget(input)).toEqual({
type: "keyword",
value: input.trim(),
});
});
it("falls back to keyword when input has spaces but contains a dot", () => {
expect(detectTarget("Visit example.com today")).toEqual({
type: "keyword",
value: "Visit example.com today",
});
});
it("falls back to keyword when domain normalization throws", () => {
expect(detectTarget("not a real domain.")).toEqual({
type: "keyword",
value: "not a real domain.",
});
});
it("trims whitespace before classification", () => {
expect(detectTarget(" opus.pro ")).toEqual({
type: "domain",
value: "opus.pro",
});
});
});

View File

@ -0,0 +1,30 @@
import { normalizeDomain } from "@/types/schemas/domain";
type DetectedTarget = {
type: "domain" | "keyword";
value: string;
};
/**
* Decide whether free-text input is a domain (e.g. "opus.pro") or a brand
* keyword (e.g. "Opus Clip"). Heuristic: no whitespace + contains a dot +
* `normalizeDomain` produces a valid hostname.
*/
export function detectTarget(rawInput: string): DetectedTarget {
const trimmed = rawInput.trim();
const looksLikeDomain =
trimmed.length > 0 && !/\s/.test(trimmed) && trimmed.includes(".");
if (looksLikeDomain) {
try {
const hostname = normalizeDomain(trimmed);
if (hostname.includes(".")) {
return { type: "domain", value: hostname };
}
} catch {
// Fall through to keyword.
}
}
return { type: "keyword", value: trimmed };
}

View File

@ -65,6 +65,13 @@ vi.mock("@/server/lib/dataforseoBacklinks", () => ({
fetchReferringDomainsRaw: vi.fn(), fetchReferringDomainsRaw: vi.fn(),
})); }));
vi.mock("@/server/lib/dataforseoLlm", () => ({
fetchLlmResponseRaw: vi.fn(),
fetchLlmAggregatedMetricsRaw: vi.fn(),
fetchLlmMentionsSearchRaw: vi.fn(),
fetchLlmTopPagesRaw: vi.fn(),
}));
import { import {
createDataforseoClient, createDataforseoClient,
mapDataforseoPathToCreditFeature, mapDataforseoPathToCreditFeature,
@ -326,4 +333,34 @@ describe("mapDataforseoPathToCreditFeature", () => {
]), ]),
).toBe("site_audit"); ).toBe("site_audit");
}); });
it("maps real ai_optimization paths to ai_search", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
"llm_mentions",
"search",
"live",
]),
).toBe("ai_search");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
"llm_mentions",
"aggregated_metrics",
"live",
]),
).toBe("ai_search");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
"claude",
"llm_responses",
"live",
]),
).toBe("ai_search");
});
}); });

View File

@ -20,6 +20,16 @@ import {
type LabsKeywordDataItem, type LabsKeywordDataItem,
type SerpLiveItem, type SerpLiveItem,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import {
fetchLlmAggregatedMetricsRaw,
fetchLlmMentionsSearchRaw,
fetchLlmResponseRaw,
fetchLlmTopPagesRaw,
type LlmAggregatedMetricsInput,
type LlmMentionsSearchInput,
type LlmResponsesInput,
type LlmTopPagesInput,
} from "@/server/lib/dataforseoLlm";
import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse"; import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload"; import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload"; import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
@ -46,7 +56,8 @@ type CreditFeature =
| "domain_overview" | "domain_overview"
| "backlinks" | "backlinks"
| "site_audit" | "site_audit"
| "rank_tracking"; | "rank_tracking"
| "ai_search";
/** /**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"]) * Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
@ -65,6 +76,8 @@ export function mapDataforseoPathToCreditFeature(
return "backlinks"; return "backlinks";
case "serp": case "serp":
return "keyword_research"; return "keyword_research";
case "ai_optimization":
return "ai_search";
case "dataforseo_labs": { case "dataforseo_labs": {
const endpoint = path[3] ?? ""; const endpoint = path[3] ?? "";
if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") { if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") {
@ -242,6 +255,24 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
); );
}, },
}, },
aiSearch: {
mentionsSearch(input: LlmMentionsSearchInput) {
return meterDataforseoCall(customer, () =>
fetchLlmMentionsSearchRaw(input),
);
},
aggregatedMetrics(input: LlmAggregatedMetricsInput) {
return meterDataforseoCall(customer, () =>
fetchLlmAggregatedMetricsRaw(input),
);
},
topPages(input: LlmTopPagesInput) {
return meterDataforseoCall(customer, () => fetchLlmTopPagesRaw(input));
},
llmResponse(input: LlmResponsesInput) {
return meterDataforseoCall(customer, () => fetchLlmResponseRaw(input));
},
},
} as const; } as const;
} }

View File

@ -0,0 +1,362 @@
import { z } from "zod";
import {
llmAggregatedTotalSchema,
llmMentionItemSchema,
llmResponseEnvelopeSchema,
llmResponseResultSchema,
llmTopPagesItemSchema,
type LlmAggregatedTotal,
type LlmDataforseoTask,
type LlmMentionItem,
type LlmResponseResult,
type LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
/**
* Raw HTTP wrappers for DataForSEO AI Optimization endpoints.
*
* The official `dataforseo-client` SDK doesn't ship typed bindings for these
* endpoints yet, so we POST raw JSON. Every wrapper returns billing metadata
* alongside parsed data so the calling client can meter usage with Autumn.
*/
const API_BASE = "https://api.dataforseo.com";
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
// ChatGPT mention/response data is only available for US/en per DataForSEO docs.
export const CHATGPT_LOCATION_CODE = 2840;
export const CHATGPT_LANGUAGE_CODE = "en";
export type LlmPlatform = "chat_gpt" | "google";
// ---------------------------------------------------------------------------
// Shared HTTP / response handling
// ---------------------------------------------------------------------------
async function postLlm(path: string, payload: unknown): Promise<unknown> {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
const response = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Basic ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const rawText = await response.text();
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`,
);
}
try {
return JSON.parse(rawText);
} catch {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned non-JSON response: ${truncate(rawText)}`,
);
}
}
function truncate(text: string): string {
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
: text;
}
function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask {
const envelope = llmResponseEnvelopeSchema.safeParse(raw);
if (!envelope.success) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned an invalid envelope: ${envelope.error.issues
.slice(0, 3)
.map((i) => i.message)
.join("; ")}`,
);
}
const data = envelope.data;
if (data.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
data.status_message || `DataForSEO ${path} request failed`,
);
}
const task = data.tasks?.[0];
if (!task) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} response missing task`,
);
}
if (task.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
task.status_message || `DataForSEO ${path} task failed`,
);
}
return task;
}
function buildBilling(task: LlmDataforseoTask) {
return {
path: task.path,
costUsd: task.cost,
resultCount: task.result_count ?? null,
};
}
// ---------------------------------------------------------------------------
// Target builders — DataForSEO's `target` array accepts domain OR keyword
// entries. We always pass exactly one target per call.
// ---------------------------------------------------------------------------
type LlmTarget =
| {
domain: string;
include_subdomains?: boolean;
search_filter?: "include" | "exclude";
search_scope?: string[];
}
| {
keyword: string;
search_filter?: "include" | "exclude";
search_scope?: string[];
match_type?: "word_match" | "partial_match";
};
export function buildLlmTarget(input: {
type: "domain" | "keyword";
value: string;
}): LlmTarget {
if (input.type === "domain") {
return {
domain: input.value,
include_subdomains: true,
search_filter: "include",
search_scope: ["any"],
};
}
return {
keyword: input.value,
search_filter: "include",
search_scope: ["any", "brand_entities"],
match_type: "word_match",
};
}
// ---------------------------------------------------------------------------
// LLM Mentions Search Live
// ---------------------------------------------------------------------------
export type LlmMentionsSearchInput = {
target: LlmTarget;
platform: LlmPlatform;
locationCode: number;
languageCode: string;
limit?: number;
};
export async function fetchLlmMentionsSearchRaw(
input: LlmMentionsSearchInput,
): Promise<DataforseoApiResponse<LlmMentionItem[]>> {
const path = "/v3/ai_optimization/llm_mentions/search/live";
const payload = [
{
target: [input.target],
platform: input.platform,
location_code: input.locationCode,
language_code: input.languageCode,
limit: clampLimit(input.limit ?? 100, 1, 1000),
},
];
const raw = await postLlm(path, payload);
const task = parseEnvelope(path, raw);
const items = z
.array(llmMentionItemSchema)
.safeParse(extractItems(task.result));
if (!items.success) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned an invalid mention items shape`,
);
}
return { data: items.data, billing: buildBilling(task) };
}
// ---------------------------------------------------------------------------
// LLM Mentions Aggregated Metrics Live
// ---------------------------------------------------------------------------
export type LlmAggregatedMetricsInput = {
target: LlmTarget;
platform: LlmPlatform;
locationCode: number;
languageCode: string;
internalListLimit?: number;
};
export async function fetchLlmAggregatedMetricsRaw(
input: LlmAggregatedMetricsInput,
): Promise<DataforseoApiResponse<LlmAggregatedTotal>> {
const path = "/v3/ai_optimization/llm_mentions/aggregated_metrics/live";
const payload = [
{
target: [input.target],
platform: input.platform,
location_code: input.locationCode,
language_code: input.languageCode,
internal_list_limit: clampLimit(input.internalListLimit ?? 10, 1, 20),
},
];
const raw = await postLlm(path, payload);
const task = parseEnvelope(path, raw);
const totalRaw = extractFirstResult(task.result)?.total ?? {};
const total = llmAggregatedTotalSchema.safeParse(totalRaw);
if (!total.success) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned an invalid aggregated metrics shape`,
);
}
return { data: total.data, billing: buildBilling(task) };
}
// ---------------------------------------------------------------------------
// LLM Mentions Top Pages Live
// ---------------------------------------------------------------------------
export type LlmTopPagesInput = {
target: LlmTarget;
platform: LlmPlatform;
locationCode: number;
languageCode: string;
itemsListLimit?: number;
};
export async function fetchLlmTopPagesRaw(
input: LlmTopPagesInput,
): Promise<DataforseoApiResponse<LlmTopPagesItem[]>> {
const path = "/v3/ai_optimization/llm_mentions/top_pages/live";
const payload = [
{
target: [input.target],
platform: input.platform,
location_code: input.locationCode,
language_code: input.languageCode,
links_scope: "sources",
items_list_limit: clampLimit(input.itemsListLimit ?? 10, 1, 10),
internal_list_limit: 5,
},
];
const raw = await postLlm(path, payload);
const task = parseEnvelope(path, raw);
const items = z
.array(llmTopPagesItemSchema)
.safeParse(extractFirstResult(task.result)?.items ?? []);
if (!items.success) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned an invalid top pages shape`,
);
}
return { data: items.data, billing: buildBilling(task) };
}
// ---------------------------------------------------------------------------
// LLM Responses Live (per-model)
// ---------------------------------------------------------------------------
export type LlmResponseModelSlug =
| "chat_gpt"
| "claude"
| "gemini"
| "perplexity";
export type LlmResponsesInput = {
userPrompt: string;
modelSlug: LlmResponseModelSlug;
modelName: string;
webSearch?: boolean;
maxOutputTokens?: number;
/** Two-letter ISO country code used to geolocate the web-search component. */
webSearchCountryCode?: string;
};
export async function fetchLlmResponseRaw(
input: LlmResponsesInput,
): Promise<DataforseoApiResponse<LlmResponseResult>> {
const path = `/v3/ai_optimization/${input.modelSlug}/llm_responses/live`;
const payload = [
{
user_prompt: input.userPrompt,
model_name: input.modelName,
web_search: input.webSearch ?? true,
max_output_tokens: clampLimit(input.maxOutputTokens ?? 1024, 256, 4096),
...(input.webSearchCountryCode && {
web_search_country_iso_code: input.webSearchCountryCode,
}),
},
];
const raw = await postLlm(path, payload);
const task = parseEnvelope(path, raw);
const first = extractFirstResult(task.result) as unknown;
const result = llmResponseResultSchema.safeParse(first ?? {});
if (!result.success) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned an invalid response shape`,
);
}
return { data: result.data, billing: buildBilling(task) };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function clampLimit(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.floor(value)));
}
function extractFirstResult(
result: LlmDataforseoTask["result"],
): Record<string, unknown> | null {
const first = result?.[0];
return isRecord(first) ? first : null;
}
function extractItems(result: LlmDataforseoTask["result"]): unknown[] {
const first = extractFirstResult(result);
if (!first) return [];
const items = first.items;
return Array.isArray(items) ? items : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1,159 @@
import { z } from "zod";
/**
* Zod schemas for DataForSEO AI Optimization endpoints.
*
* The DataForSEO SDK does not yet ship typed bindings for `/ai_optimization/*`,
* so we POST raw JSON and validate the responses ourselves. All schemas use
* `.passthrough()` to tolerate fields the API may add in future versions.
*/
// ---------------------------------------------------------------------------
// LLM Mentions — shared bits
// ---------------------------------------------------------------------------
const monthlyVolumeSchema = z
.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
search_volume: z.number().nullable().optional(),
})
.passthrough();
const mentionSourceSchema = z
.object({
url: z.string().nullable().optional(),
title: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
})
.passthrough();
const brandEntitySchema = z
.object({
title: z.string().nullable().optional(),
})
.passthrough();
// ---------------------------------------------------------------------------
// LLM Mentions Search — `/v3/ai_optimization/llm_mentions/search/live`
// Returns one row per LLM answer that matched the target.
// ---------------------------------------------------------------------------
export const llmMentionItemSchema = z
.object({
question: z.string().nullable().optional(),
sources: z.array(mentionSourceSchema).nullable().optional(),
ai_search_volume: z.number().nullable().optional(),
monthly_searches: z.array(monthlyVolumeSchema).nullable().optional(),
first_response_at: z.string().nullable().optional(),
last_response_at: z.string().nullable().optional(),
brand_entities: z.array(brandEntitySchema).nullable().optional(),
})
.passthrough();
export type LlmMentionItem = z.infer<typeof llmMentionItemSchema>;
// ---------------------------------------------------------------------------
// LLM Mentions Aggregated Metrics — `/v3/ai_optimization/llm_mentions/aggregated_metrics/live`
// Each metric category contains an array of group elements with mention counts.
// ---------------------------------------------------------------------------
const groupElementSchema = z
.object({
type: z.string().nullable().optional(),
key: z.string().nullable().optional(),
mentions: z.number().nullable().optional(),
ai_search_volume: z.number().nullable().optional(),
impressions: z.number().nullable().optional(),
})
.passthrough();
export const llmAggregatedTotalSchema = z
.object({
platform: z.array(groupElementSchema).nullable().optional(),
})
.passthrough();
export type LlmAggregatedTotal = z.infer<typeof llmAggregatedTotalSchema>;
// ---------------------------------------------------------------------------
// LLM Mentions Top Pages — `/v3/ai_optimization/llm_mentions/top_pages/live`
// Each item has `key` = page URL plus the same group-element arrays.
// ---------------------------------------------------------------------------
export const llmTopPagesItemSchema = z
.object({
key: z.string().nullable().optional(),
platform: z.array(groupElementSchema).nullable().optional(),
})
.passthrough();
export type LlmTopPagesItem = z.infer<typeof llmTopPagesItemSchema>;
// ---------------------------------------------------------------------------
// LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity
// All four model endpoints return the same envelope shape.
// ---------------------------------------------------------------------------
const responseAnnotationSchema = z
.object({
type: z.string().nullable().optional(),
title: z.string().nullable().optional(),
url: z.string().nullable().optional(),
})
.passthrough();
const responseSectionSchema = z
.object({
type: z.string().nullable().optional(),
text: z.string().nullable().optional(),
annotations: z.array(responseAnnotationSchema).nullable().optional(),
})
.passthrough();
const responseItemSchema = z
.object({
type: z.string().nullable().optional(),
sections: z.array(responseSectionSchema).nullable().optional(),
})
.passthrough();
export const llmResponseResultSchema = z
.object({
model_name: z.string().nullable().optional(),
output_tokens: z.number().nullable().optional(),
web_search: z.boolean().nullable().optional(),
items: z.array(responseItemSchema).nullable().optional(),
fan_out_queries: z.array(z.string()).nullable().optional(),
})
.passthrough();
export type LlmResponseResult = z.infer<typeof llmResponseResultSchema>;
// ---------------------------------------------------------------------------
// Top-level envelope used by every AI Optimization endpoint.
// We re-declare here (instead of reusing dataforseoSchemas.ts) because the
// `result` shape differs from Labs/SERP — items are not always under
// `result[0].items` and totals/items can both be present.
// ---------------------------------------------------------------------------
const llmTaskSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
path: z.array(z.string()),
cost: z.number(),
result_count: z.number().nullable().optional(),
result: z.array(z.unknown()).nullable().optional(),
})
.passthrough();
export type LlmDataforseoTask = z.infer<typeof llmTaskSchema>;
export const llmResponseEnvelopeSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z.array(llmTaskSchema).optional(),
})
.passthrough();

View File

@ -0,0 +1,41 @@
import { createServerFn } from "@tanstack/react-start";
import { getBrandLookup } from "@/server/features/ai-search/services/brandLookup";
import { explorePrompt as runExplorePrompt } from "@/server/features/ai-search/services/promptExplorer";
import { customerHasPaidPlan } from "@/server/billing/subscription";
import { AppError } from "@/server/lib/errors";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
brandLookupInputSchema,
promptExplorerInputSchema,
} from "@/types/schemas/ai-search";
/**
* AI Visibility endpoints are gated behind the paid plan in hosted mode
* because each call fans out to several paid DataForSEO requests. Self-hosted
* deployments pay DataForSEO directly and aren't gated.
*/
async function assertPaidPlan(organizationId: string) {
if (!(await isHostedServerAuthMode())) return;
if (await customerHasPaidPlan(organizationId)) return;
throw new AppError(
"PAYMENT_REQUIRED",
"Upgrade to the paid plan to use AI Visibility",
);
}
export const lookupBrand = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => brandLookupInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertPaidPlan(context.organizationId);
return getBrandLookup({ ...data, projectId: context.projectId }, context);
});
export const explorePrompt = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => promptExplorerInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertPaidPlan(context.organizationId);
return runExplorePrompt({ ...data, projectId: context.projectId }, context);
});

View File

@ -17,3 +17,15 @@ export function roundUsdForBilling(value: number) {
export function autumnSeoDataCreditsToUsd(credits: number) { export function autumnSeoDataCreditsToUsd(credits: number) {
return credits / AUTUMN_SEO_DATA_CREDITS_PER_USD; return credits / AUTUMN_SEO_DATA_CREDITS_PER_USD;
} }
/**
* Convert a raw DataForSEO USD cost into the USD amount a hosted customer is
* actually billed, applying the platform markup. Use this when displaying
* cost estimates so the number matches what the user will be charged.
*
* Self-hosted deployments pay DataForSEO directly at the raw rate and should
* show the raw number gate at the call site with `isHostedClientAuthMode`.
*/
export function applyBillingMarkupUsd(rawUsd: number): number {
return roundUsdForBilling(rawUsd * SEO_DATA_COST_MARKUP);
}

View File

@ -0,0 +1,203 @@
import { z } from "zod";
/**
* Input + output schemas for the AI Search feature (Brand Lookup + Prompt
* Explorer). These two pages are fully stateless the user types something,
* we hit DataForSEO, and we render. Schemas live here so they can be reused
* by server functions, services, R2 cache validation, and the client UI.
*/
// ---------------------------------------------------------------------------
// Brand Lookup
// ---------------------------------------------------------------------------
/** Maximum allowed length for a free-text brand or domain search input. */
export const BRAND_LOOKUP_MAX_INPUT_LENGTH = 250;
export const brandLookupInputSchema = z.object({
projectId: z.string().min(1),
query: z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
});
export type BrandLookupInput = z.infer<typeof brandLookupInputSchema>;
const brandPlatformBreakdownSchema = z.object({
platform: z.enum(["chat_gpt", "google"]),
status: z.enum(["success", "error"]),
mentions: z.number().int().nonnegative().nullable(),
aiSearchVolume: z.number().int().nonnegative().nullable(),
impressions: z.number().int().nonnegative().nullable(),
});
const brandTopPageSchema = z.object({
url: z.string(),
domain: z.string().nullable(),
mentions: z.number().int().nonnegative().nullable(),
platform: z.enum(["chat_gpt", "google"]),
});
const brandTopQuerySchema = z.object({
question: z.string(),
platform: z.enum(["chat_gpt", "google"]),
aiSearchVolume: z.number().int().nonnegative().nullable(),
firstSeenAt: z.string().nullable(),
lastSeenAt: z.string().nullable(),
citedSources: z
.array(
z.object({
url: z.string(),
domain: z.string().nullable(),
title: z.string().nullable(),
}),
)
.max(10),
brandsMentioned: z.array(z.string()).max(20),
});
const brandMonthlyVolumeSchema = z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
volume: z.number().int().nonnegative().nullable(),
});
export const brandLookupResultSchema = z.object({
query: z.string(),
detectedTargetType: z.enum(["domain", "keyword"]),
resolvedTarget: z.string(),
fetchedAt: z.string(),
hasData: z.boolean(),
totalMentions: z.number().int().nonnegative().nullable(),
totalAiSearchVolume: z.number().int().nonnegative().nullable(),
totalImpressions: z.number().int().nonnegative().nullable(),
perPlatform: z.array(brandPlatformBreakdownSchema),
topPages: z.array(brandTopPageSchema).max(20),
topQueries: z.array(brandTopQuerySchema).max(50),
monthlyVolume: z.array(brandMonthlyVolumeSchema),
});
export type BrandLookupResult = z.infer<typeof brandLookupResultSchema>;
// ---------------------------------------------------------------------------
// Prompt Explorer
// ---------------------------------------------------------------------------
export const PROMPT_EXPLORER_MAX_PROMPT_LENGTH = 500;
/** Stable identifiers for the four LLM models we expose. */
export const PROMPT_EXPLORER_MODELS = [
"chat_gpt",
"claude",
"gemini",
"perplexity",
] as const;
export const promptExplorerModelSchema = z.enum(PROMPT_EXPLORER_MODELS);
export type PromptExplorerModel = z.infer<typeof promptExplorerModelSchema>;
/**
* Two-letter ISO country code passed as `web_search_country_iso_code` to each
* LLM Responses endpoint. Affects the web-search component of the answer
* (Perplexity, GPT-5, Gemini, Claude when web search is on). DataForSEO
* accepts any ISO-2 for ChatGPT/Gemini; Claude/Perplexity have a finite
* supported list. We only expose codes covered by all four.
*/
export const WEB_SEARCH_COUNTRY_CODES = [
"US",
"GB",
"CA",
"AU",
"IE",
"DE",
"FR",
"ES",
"IT",
"NL",
"PT",
"PL",
"SE",
"NO",
"DK",
"BR",
"MX",
"IN",
"JP",
"KR",
"SG",
"HK",
"TW",
"ZA",
] as const;
export const webSearchCountryCodeSchema = z.enum(WEB_SEARCH_COUNTRY_CODES);
export type WebSearchCountryCode = z.infer<typeof webSearchCountryCodeSchema>;
export const promptExplorerInputSchema = z.object({
projectId: z.string().min(1),
prompt: z.string().trim().min(1).max(PROMPT_EXPLORER_MAX_PROMPT_LENGTH),
models: z.array(promptExplorerModelSchema).min(1).max(4),
highlightBrand: z
.string()
.trim()
.min(1)
.max(BRAND_LOOKUP_MAX_INPUT_LENGTH)
.optional(),
webSearch: z.boolean().default(true),
webSearchCountryCode: webSearchCountryCodeSchema.optional(),
});
export type PromptExplorerInput = z.infer<typeof promptExplorerInputSchema>;
const promptExplorerCitationSchema = z.object({
url: z.string(),
domain: z.string().nullable(),
title: z.string().nullable(),
matchedBrand: z.boolean(),
});
export type PromptExplorerCitation = z.infer<
typeof promptExplorerCitationSchema
>;
export const promptExplorerModelResultSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
model: promptExplorerModelSchema,
modelName: z.string().nullable(),
text: z.string(),
citations: z.array(promptExplorerCitationSchema),
fanOutQueries: z.array(z.string()),
brandMentioned: z.boolean().nullable(),
outputTokens: z.number().int().nonnegative().nullable(),
webSearch: z.boolean(),
}),
z.object({
status: z.literal("error"),
model: promptExplorerModelSchema,
errorCode: z.literal("UPSTREAM_ERROR"),
message: z.string(),
}),
]);
export type PromptExplorerModelResult = z.infer<
typeof promptExplorerModelResultSchema
>;
export const promptExplorerResultSchema = z.object({
prompt: z.string(),
highlightBrand: z.string().nullable(),
fetchedAt: z.string(),
results: z.array(promptExplorerModelResultSchema),
});
export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
// ---------------------------------------------------------------------------
// URL search params
// ---------------------------------------------------------------------------
/** /p/$projectId/brand-lookup query params — `q` keeps the lookup shareable. */
export const brandLookupSearchSchema = z.object({
q: z.string().optional(),
});