feat: AI Visibility — Brand Lookup and Prompt Explorer (#128)
This commit is contained in:
parent
da2509ec74
commit
faf7a294a3
@ -28,6 +28,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run --reporter=dot",
|
||||
"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",
|
||||
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||
},
|
||||
@ -71,7 +72,9 @@
|
||||
"posthog-node": "^5.28.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remeda": "^2.33.6",
|
||||
"robots-parser": "^3.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
891
pnpm-lock.yaml
generated
891
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
191
scripts/brand-lookup-cost-profile.ts
Normal file
191
scripts/brand-lookup-cost-profile.ts
Normal 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);
|
||||
}
|
||||
43
src/client/components/table/SortableHeader.tsx
Normal file
43
src/client/components/table/SortableHeader.tsx
Normal 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;
|
||||
}
|
||||
76
src/client/components/table/nullSafeSort.ts
Normal file
76
src/client/components/table/nullSafeSort.ts
Normal 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);
|
||||
}
|
||||
210
src/client/features/ai-search/BrandLookupPage.tsx
Normal file
210
src/client/features/ai-search/BrandLookupPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
src/client/features/ai-search/PromptExplorerPage.tsx
Normal file
256
src/client/features/ai-search/PromptExplorerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/client/features/ai-search/brandLookupFilterTypes.ts
Normal file
33
src/client/features/ai-search/brandLookupFilterTypes.ts
Normal 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: "",
|
||||
};
|
||||
93
src/client/features/ai-search/brandLookupFiltering.ts
Normal file
93
src/client/features/ai-search/brandLookupFiltering.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
420
src/client/features/ai-search/components/BrandLookupResults.tsx
Normal file
420
src/client/features/ai-search/components/BrandLookupResults.tsx
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
282
src/client/features/ai-search/components/MarkdownAnswer.tsx
Normal file
282
src/client/features/ai-search/components/MarkdownAnswer.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
190
src/client/features/ai-search/components/PromptExplorerForm.tsx
Normal file
190
src/client/features/ai-search/components/PromptExplorerForm.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
96
src/client/features/ai-search/platformLabels.ts
Normal file
96
src/client/features/ai-search/platformLabels.ts
Normal 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);
|
||||
}
|
||||
24
src/client/features/ai-search/urlDisplay.ts
Normal file
24
src/client/features/ai-search/urlDisplay.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
94
src/client/features/ai-search/useBrandLookupFilters.ts
Normal file
94
src/client/features/ai-search/useBrandLookupFilters.ts
Normal 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>;
|
||||
@ -1,5 +1,11 @@
|
||||
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 { BacklinksSourceLink } from "./BacklinksPageLinks";
|
||||
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>[] = [
|
||||
{
|
||||
id: "source",
|
||||
@ -148,7 +115,7 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: "alphanumeric",
|
||||
sortingFn: stringNullsLast,
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
@ -157,7 +124,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
),
|
||||
size: 220,
|
||||
minSize: 150,
|
||||
accessorFn: (row) => row.targetCount,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (row.depth > 0) {
|
||||
@ -175,20 +141,15 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
|
||||
return null;
|
||||
},
|
||||
sortingFn: "basic",
|
||||
},
|
||||
{
|
||||
id: "anchor",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
column={column}
|
||||
label="Anchor"
|
||||
helpText="Text or format of the link"
|
||||
/>
|
||||
header: () => (
|
||||
<HeaderHelpLabel label="Anchor" helpText="Text or format of the link" />
|
||||
),
|
||||
size: 150,
|
||||
minSize: 100,
|
||||
accessorFn: (row) => row._backlink?.anchor ?? row.domain,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (row.depth > 0) {
|
||||
const child = row.original._backlink;
|
||||
@ -206,8 +167,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
|
||||
return null;
|
||||
},
|
||||
sortingFn: "alphanumeric",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "flags",
|
||||
@ -236,17 +195,17 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
},
|
||||
{
|
||||
id: "linkAuthority",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
column={column}
|
||||
label="Link"
|
||||
helpText="Authority of the linking page"
|
||||
align="right"
|
||||
/>
|
||||
header: () => (
|
||||
<span className="flex w-full justify-end">
|
||||
<HeaderHelpLabel
|
||||
label="Link"
|
||||
helpText="Authority of the linking page"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
size: 70,
|
||||
minSize: 50,
|
||||
accessorFn: (row) => row._backlink?.rank ?? null,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (row.depth > 0) {
|
||||
const child = row.original._backlink;
|
||||
@ -267,8 +226,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
|
||||
return null;
|
||||
},
|
||||
sortingFn: "basic",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "domainAuthority",
|
||||
@ -292,7 +249,8 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: "basic",
|
||||
sortingFn: numericNullsLast,
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
id: "spamScore",
|
||||
@ -319,7 +277,8 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: "basic",
|
||||
sortingFn: numericNullsLast,
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
id: "firstSeen",
|
||||
@ -354,6 +313,7 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
sortingFn: dateNullsLast,
|
||||
sortDescFirst: true,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,27 +1,163 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||
import { ReferringDomainsTableHeader } from "./BacklinksTableHeaders";
|
||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||
import {
|
||||
DEFAULT_REFERRING_DOMAINS_SORT,
|
||||
sortReferringDomainRows,
|
||||
} from "./backlinksTableSorting";
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
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 {
|
||||
formatCompactDate,
|
||||
formatDecimal,
|
||||
formatNumber,
|
||||
} 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({
|
||||
rows,
|
||||
}: {
|
||||
rows: BacklinksOverviewData["referringDomains"];
|
||||
}) {
|
||||
const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT);
|
||||
const sortedRows = useMemo(
|
||||
() => sortReferringDomainRows(rows, sort),
|
||||
[rows, sort],
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyTableState label="No referring domains match this filter." />;
|
||||
@ -30,24 +166,37 @@ export function ReferringDomainsTable({
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
{sortedRows.map((row, index) => (
|
||||
<tr key={`${row.domain ?? "domain"}-${index}`}>
|
||||
<td className="font-medium break-all">{row.domain ?? "-"}</td>
|
||||
<td>{formatNumber(row.backlinks)}</td>
|
||||
<td>{formatNumber(row.referringPages)}</td>
|
||||
<td>{formatNumber(row.rank)}</td>
|
||||
<td>{formatDecimal(row.spamScore)}</td>
|
||||
<td>{formatCompactDate(row.firstSeen)}</td>
|
||||
<td>
|
||||
<div className="text-sm">
|
||||
<div>Broken links: {formatNumber(row.brokenBacklinks)}</div>
|
||||
<div className="text-base-content/55">
|
||||
Broken pages: {formatNumber(row.brokenPages)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={
|
||||
cell.column.id === "domain"
|
||||
? "font-medium break-all"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -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 { BacklinksExternalLink } from "./BacklinksPageLinks";
|
||||
import { TopPagesTableHeader } from "./BacklinksTableHeaders";
|
||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||
import {
|
||||
DEFAULT_TOP_PAGES_SORT,
|
||||
sortTopPageRows,
|
||||
} from "./backlinksTableSorting";
|
||||
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({
|
||||
rows,
|
||||
}: {
|
||||
rows: BacklinksOverviewData["topPages"];
|
||||
}) {
|
||||
const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT);
|
||||
const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]);
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyTableState label="No top pages match this filter." />;
|
||||
@ -24,25 +119,33 @@ export function TopPagesTable({
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<TopPagesTableHeader 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>
|
||||
{sortedRows.map((row, index) => (
|
||||
<tr key={`${row.page ?? "page"}-${index}`}>
|
||||
<td className="min-w-80">
|
||||
{row.page ? (
|
||||
<BacklinksExternalLink
|
||||
url={row.page}
|
||||
label={row.page}
|
||||
className="link link-hover break-all inline-flex items-center gap-1"
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td>{formatNumber(row.backlinks)}</td>
|
||||
<td>{formatNumber(row.referringDomains)}</td>
|
||||
<td>{formatNumber(row.rank)}</td>
|
||||
<td>{formatNumber(row.brokenBacklinks)}</td>
|
||||
{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>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -12,8 +12,10 @@ function passesNumericFilter(
|
||||
max: string,
|
||||
): boolean {
|
||||
if (value == null) return true;
|
||||
if (min && value < Number(min)) return false;
|
||||
if (max && value > Number(max)) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -25,7 +25,7 @@ export function DomainSearchCard({
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-4">
|
||||
<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}
|
||||
>
|
||||
<controlsForm.Field name="domain">
|
||||
@ -34,10 +34,11 @@ export function DomainSearchCard({
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<input
|
||||
className="grow"
|
||||
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
@ -54,7 +55,7 @@ export function DomainSearchCard({
|
||||
<controlsForm.Field name="locationCode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered lg:col-span-2"
|
||||
className="select select-bordered shrink-0"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
@ -74,7 +75,7 @@ export function DomainSearchCard({
|
||||
<controlsForm.Field name="sort">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered lg:col-span-2"
|
||||
className="select select-bordered shrink-0"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
const next = toSortMode(event.target.value) ?? "rank";
|
||||
@ -95,7 +96,7 @@ export function DomainSearchCard({
|
||||
{(isSubmitting) => (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary lg:col-span-2"
|
||||
className="btn btn-primary shrink-0 px-6"
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading || isSubmitting ? "Loading..." : "Search"}
|
||||
|
||||
20
src/client/hooks/useBrandLookupSearchHistory.ts
Normal file
20
src/client/hooks/useBrandLookupSearchHistory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
48
src/client/hooks/usePromptExplorerSearchHistory.ts
Normal file
48
src/client/hooks/usePromptExplorerSearchHistory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
46
src/client/hooks/useTimestampedSearchHistory.ts
Normal file
46
src/client/hooks/useTimestampedSearchHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -205,7 +205,7 @@ function TopNav({
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={entry.label} className="dropdown">
|
||||
<div key={entry.label} className="dropdown dropdown-hover">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
@ -221,7 +221,7 @@ function TopNav({
|
||||
</button>
|
||||
<ul
|
||||
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) => {
|
||||
const { icon: Icon, matchSegment, ...linkProps } = item;
|
||||
|
||||
@ -4,7 +4,9 @@ import {
|
||||
ClipboardCheck,
|
||||
Globe,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { linkOptions } from "@tanstack/react-router";
|
||||
@ -46,9 +48,21 @@ const projectNavItems = [
|
||||
icon: ClipboardCheck,
|
||||
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,
|
||||
label: "AI",
|
||||
label: "AI & Agents",
|
||||
icon: Bot,
|
||||
matchSegment: "/ai",
|
||||
},
|
||||
@ -91,6 +105,13 @@ export function getProjectNavGroups(projectId: string) {
|
||||
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,
|
||||
item: bySegment("/ai"),
|
||||
|
||||
@ -108,7 +108,9 @@ select {
|
||||
|
||||
/* Global input focus styling - use primary color instead of default ring */
|
||||
.input:focus,
|
||||
.input:focus-visible {
|
||||
.input:focus-visible,
|
||||
.textarea:focus,
|
||||
.textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
|
||||
@ -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 ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||
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 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 ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
||||
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
||||
@ -136,6 +138,12 @@ const ProjectPProjectIdRankTrackingRoute =
|
||||
path: '/rank-tracking',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdPromptExplorerRoute =
|
||||
ProjectPProjectIdPromptExplorerRouteImport.update({
|
||||
id: '/prompt-explorer',
|
||||
path: '/prompt-explorer',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdKeywordsRoute =
|
||||
ProjectPProjectIdKeywordsRouteImport.update({
|
||||
id: '/keywords',
|
||||
@ -147,6 +155,12 @@ const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
||||
path: '/domain',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdBrandLookupRoute =
|
||||
ProjectPProjectIdBrandLookupRouteImport.update({
|
||||
id: '/brand-lookup',
|
||||
path: '/brand-lookup',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdBacklinksRoute =
|
||||
ProjectPProjectIdBacklinksRouteImport.update({
|
||||
id: '/backlinks',
|
||||
@ -205,8 +219,10 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
@ -230,8 +246,10 @@ export interface FileRoutesByTo {
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
@ -261,8 +279,10 @@ export interface FileRoutesById {
|
||||
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
@ -290,8 +310,10 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/rank-tracking'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId/'
|
||||
@ -315,8 +337,10 @@ export interface FileRouteTypes {
|
||||
| '/api/autumn/$'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId'
|
||||
| '/p/$projectId/rank-tracking/$configId'
|
||||
@ -345,8 +369,10 @@ export interface FileRouteTypes {
|
||||
| '/_project/p/$projectId/ai'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
| '/_project/p/$projectId/brand-lookup'
|
||||
| '/_project/p/$projectId/domain'
|
||||
| '/_project/p/$projectId/keywords'
|
||||
| '/_project/p/$projectId/prompt-explorer'
|
||||
| '/_project/p/$projectId/rank-tracking'
|
||||
| '/_project/p/$projectId/saved'
|
||||
| '/_project/p/$projectId/'
|
||||
@ -510,6 +536,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
|
||||
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': {
|
||||
id: '/_project/p/$projectId/keywords'
|
||||
path: '/keywords'
|
||||
@ -524,6 +557,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdDomainRouteImport
|
||||
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': {
|
||||
id: '/_project/p/$projectId/backlinks'
|
||||
path: '/backlinks'
|
||||
@ -633,8 +673,10 @@ interface ProjectPProjectIdRouteRouteChildren {
|
||||
ProjectPProjectIdAiRoute: typeof ProjectPProjectIdAiRoute
|
||||
ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
||||
@ -645,8 +687,10 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
||||
ProjectPProjectIdAiRoute: ProjectPProjectIdAiRoute,
|
||||
ProjectPProjectIdAuditRoute: ProjectPProjectIdAuditRouteWithChildren,
|
||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
||||
ProjectPProjectIdRankTrackingRoute:
|
||||
ProjectPProjectIdRankTrackingRouteWithChildren,
|
||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||
|
||||
30
src/routes/_project/p/$projectId/brand-lookup.tsx
Normal file
30
src/routes/_project/p/$projectId/brand-lookup.tsx
Normal 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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
src/routes/_project/p/$projectId/prompt-explorer.tsx
Normal file
11
src/routes/_project/p/$projectId/prompt-explorer.tsx
Normal 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} />;
|
||||
}
|
||||
50
src/server/features/ai-search/safeUrl.test.ts
Normal file
50
src/server/features/ai-search/safeUrl.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
src/server/features/ai-search/safeUrl.ts
Normal file
39
src/server/features/ai-search/safeUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
413
src/server/features/ai-search/services/brandLookup.ts
Normal file
413
src/server/features/ai-search/services/brandLookup.ts
Normal 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);
|
||||
}
|
||||
297
src/server/features/ai-search/services/promptExplorer.ts
Normal file
297
src/server/features/ai-search/services/promptExplorer.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
48
src/server/features/ai-search/targetDetection.test.ts
Normal file
48
src/server/features/ai-search/targetDetection.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/server/features/ai-search/targetDetection.ts
Normal file
30
src/server/features/ai-search/targetDetection.ts
Normal 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 };
|
||||
}
|
||||
@ -65,6 +65,13 @@ vi.mock("@/server/lib/dataforseoBacklinks", () => ({
|
||||
fetchReferringDomainsRaw: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/lib/dataforseoLlm", () => ({
|
||||
fetchLlmResponseRaw: vi.fn(),
|
||||
fetchLlmAggregatedMetricsRaw: vi.fn(),
|
||||
fetchLlmMentionsSearchRaw: vi.fn(),
|
||||
fetchLlmTopPagesRaw: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createDataforseoClient,
|
||||
mapDataforseoPathToCreditFeature,
|
||||
@ -326,4 +333,34 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
||||
]),
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,6 +20,16 @@ import {
|
||||
type LabsKeywordDataItem,
|
||||
type SerpLiveItem,
|
||||
} 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 type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
|
||||
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
|
||||
@ -46,7 +56,8 @@ type CreditFeature =
|
||||
| "domain_overview"
|
||||
| "backlinks"
|
||||
| "site_audit"
|
||||
| "rank_tracking";
|
||||
| "rank_tracking"
|
||||
| "ai_search";
|
||||
|
||||
/**
|
||||
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
||||
@ -65,6 +76,8 @@ export function mapDataforseoPathToCreditFeature(
|
||||
return "backlinks";
|
||||
case "serp":
|
||||
return "keyword_research";
|
||||
case "ai_optimization":
|
||||
return "ai_search";
|
||||
case "dataforseo_labs": {
|
||||
const endpoint = path[3] ?? "";
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
362
src/server/lib/dataforseoLlm.ts
Normal file
362
src/server/lib/dataforseoLlm.ts
Normal 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;
|
||||
}
|
||||
159
src/server/lib/dataforseoLlmSchemas.ts
Normal file
159
src/server/lib/dataforseoLlmSchemas.ts
Normal 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();
|
||||
41
src/serverFunctions/ai-search.ts
Normal file
41
src/serverFunctions/ai-search.ts
Normal 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);
|
||||
});
|
||||
@ -17,3 +17,15 @@ export function roundUsdForBilling(value: number) {
|
||||
export function autumnSeoDataCreditsToUsd(credits: number) {
|
||||
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);
|
||||
}
|
||||
|
||||
203
src/types/schemas/ai-search.ts
Normal file
203
src/types/schemas/ai-search.ts
Normal 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(),
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user