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:watch": "vitest",
|
||||||
"test:ci": "vitest run --reporter=dot",
|
"test:ci": "vitest run --reporter=dot",
|
||||||
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
||||||
|
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
|
||||||
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
||||||
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||||
},
|
},
|
||||||
@ -71,7 +72,9 @@
|
|||||||
"posthog-node": "^5.28.5",
|
"posthog-node": "^5.28.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"remeda": "^2.33.6",
|
"remeda": "^2.33.6",
|
||||||
"robots-parser": "^3.0.1",
|
"robots-parser": "^3.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
891
pnpm-lock.yaml
generated
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 type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { ArrowDown, ArrowUp, ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
|
import {
|
||||||
|
dateNullsLast,
|
||||||
|
numericNullsLast,
|
||||||
|
stringNullsLast,
|
||||||
|
} from "@/client/components/table/nullSafeSort";
|
||||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||||
import { BacklinksSourceLink } from "./BacklinksPageLinks";
|
import { BacklinksSourceLink } from "./BacklinksPageLinks";
|
||||||
import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes";
|
import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes";
|
||||||
@ -65,45 +71,6 @@ function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableHeader({
|
|
||||||
column,
|
|
||||||
label,
|
|
||||||
helpText,
|
|
||||||
align,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
|
|
||||||
};
|
|
||||||
label: string;
|
|
||||||
helpText: string;
|
|
||||||
align?: "left" | "right";
|
|
||||||
}) {
|
|
||||||
const sorted = column.getIsSorted();
|
|
||||||
const content = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 font-medium transition-colors hover:text-base-content"
|
|
||||||
onClick={column.getToggleSortingHandler()}
|
|
||||||
aria-label={`Sort by ${label}`}
|
|
||||||
aria-pressed={!!sorted}
|
|
||||||
>
|
|
||||||
<HeaderHelpLabel label={label} helpText={helpText} />
|
|
||||||
{sorted === "asc" ? (
|
|
||||||
<ArrowUp className="size-3 shrink-0" />
|
|
||||||
) : sorted === "desc" ? (
|
|
||||||
<ArrowDown className="size-3 shrink-0" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (align === "right") {
|
|
||||||
return <span className="flex w-full justify-end">{content}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
||||||
{
|
{
|
||||||
id: "source",
|
id: "source",
|
||||||
@ -148,7 +115,7 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortingFn: "alphanumeric",
|
sortingFn: stringNullsLast,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "target",
|
id: "target",
|
||||||
@ -157,7 +124,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
),
|
),
|
||||||
size: 220,
|
size: 220,
|
||||||
minSize: 150,
|
minSize: 150,
|
||||||
accessorFn: (row) => row.targetCount,
|
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (row.depth > 0) {
|
if (row.depth > 0) {
|
||||||
@ -175,20 +141,15 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
sortingFn: "basic",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "anchor",
|
id: "anchor",
|
||||||
header: ({ column }) => (
|
header: () => (
|
||||||
<SortableHeader
|
<HeaderHelpLabel label="Anchor" helpText="Text or format of the link" />
|
||||||
column={column}
|
|
||||||
label="Anchor"
|
|
||||||
helpText="Text or format of the link"
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
size: 150,
|
size: 150,
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
accessorFn: (row) => row._backlink?.anchor ?? row.domain,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (row.depth > 0) {
|
if (row.depth > 0) {
|
||||||
const child = row.original._backlink;
|
const child = row.original._backlink;
|
||||||
@ -206,8 +167,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
sortingFn: "alphanumeric",
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "flags",
|
id: "flags",
|
||||||
@ -236,17 +195,17 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "linkAuthority",
|
id: "linkAuthority",
|
||||||
header: ({ column }) => (
|
header: () => (
|
||||||
<SortableHeader
|
<span className="flex w-full justify-end">
|
||||||
column={column}
|
<HeaderHelpLabel
|
||||||
label="Link"
|
label="Link"
|
||||||
helpText="Authority of the linking page"
|
helpText="Authority of the linking page"
|
||||||
align="right"
|
/>
|
||||||
/>
|
</span>
|
||||||
),
|
),
|
||||||
size: 70,
|
size: 70,
|
||||||
minSize: 50,
|
minSize: 50,
|
||||||
accessorFn: (row) => row._backlink?.rank ?? null,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (row.depth > 0) {
|
if (row.depth > 0) {
|
||||||
const child = row.original._backlink;
|
const child = row.original._backlink;
|
||||||
@ -267,8 +226,6 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
sortingFn: "basic",
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domainAuthority",
|
id: "domainAuthority",
|
||||||
@ -292,7 +249,8 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortingFn: "basic",
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "spamScore",
|
id: "spamScore",
|
||||||
@ -319,7 +277,8 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortingFn: "basic",
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "firstSeen",
|
id: "firstSeen",
|
||||||
@ -354,6 +313,7 @@ export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortingFn: "datetime",
|
sortingFn: dateNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
DEFAULT_REFERRING_DOMAINS_SORT,
|
createColumnHelper,
|
||||||
sortReferringDomainRows,
|
flexRender,
|
||||||
} from "./backlinksTableSorting";
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type SortingFn,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
|
import {
|
||||||
|
compareNumericNullsLast,
|
||||||
|
dateNullsLast,
|
||||||
|
isDescending,
|
||||||
|
numericNullsLast,
|
||||||
|
stringNullsLast,
|
||||||
|
} from "@/client/components/table/nullSafeSort";
|
||||||
|
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||||
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
import {
|
import {
|
||||||
formatCompactDate,
|
formatCompactDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
} from "./backlinksPageUtils";
|
} from "./backlinksPageUtils";
|
||||||
|
|
||||||
|
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<ReferringDomainRow>();
|
||||||
|
|
||||||
|
// Nulls always to the bottom in both directions, same as the pre-TanStack
|
||||||
|
// implementation. Secondary compare on brokenPages must also keep nulls last —
|
||||||
|
// coercing to 0 would mix unknown values with real zeroes.
|
||||||
|
const sortByIssues: SortingFn<ReferringDomainRow> = (left, right, columnId) => {
|
||||||
|
const descending = isDescending(left, columnId);
|
||||||
|
const primary = compareNumericNullsLast(
|
||||||
|
left.original.brokenBacklinks,
|
||||||
|
right.original.brokenBacklinks,
|
||||||
|
descending,
|
||||||
|
);
|
||||||
|
if (primary !== 0) return primary;
|
||||||
|
return compareNumericNullsLast(
|
||||||
|
left.original.brokenPages,
|
||||||
|
right.original.brokenPages,
|
||||||
|
descending,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("domain", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Domain"
|
||||||
|
helpText="The referring site linking to your target."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => getValue() ?? "-",
|
||||||
|
sortingFn: stringNullsLast,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("backlinks", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Backlinks"
|
||||||
|
helpText="Total backlinks found from this domain."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("referringPages", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Referring Pages"
|
||||||
|
helpText="Unique pages on this domain that link to your target."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("rank", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Rank"
|
||||||
|
helpText="Authority score for the referring domain."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("spamScore", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Spam"
|
||||||
|
helpText="Spam risk score for this referring domain."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatDecimal(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("firstSeen", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="First Seen"
|
||||||
|
helpText="When this domain was first discovered linking to your target."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatCompactDate(getValue()),
|
||||||
|
sortingFn: dateNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "issues",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Issues"
|
||||||
|
helpText="Broken link and broken page counts tied to this domain."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>Broken links: {formatNumber(row.original.brokenBacklinks)}</div>
|
||||||
|
<div className="text-base-content/55">
|
||||||
|
Broken pages: {formatNumber(row.original.brokenPages)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: sortByIssues,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
|
||||||
|
|
||||||
export function ReferringDomainsTable({
|
export function ReferringDomainsTable({
|
||||||
rows,
|
rows,
|
||||||
}: {
|
}: {
|
||||||
rows: BacklinksOverviewData["referringDomains"];
|
rows: BacklinksOverviewData["referringDomains"];
|
||||||
}) {
|
}) {
|
||||||
const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT);
|
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||||
const sortedRows = useMemo(
|
|
||||||
() => sortReferringDomainRows(rows, sort),
|
const table = useReactTable({
|
||||||
[rows, sort],
|
data: rows,
|
||||||
);
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <EmptyTableState label="No referring domains match this filter." />;
|
return <EmptyTableState label="No referring domains match this filter." />;
|
||||||
@ -30,24 +166,37 @@ export function ReferringDomainsTable({
|
|||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="table table-sm">
|
<table className="table table-sm">
|
||||||
<ReferringDomainsTableHeader sort={sort} onSortChange={setSort} />
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedRows.map((row, index) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr key={`${row.domain ?? "domain"}-${index}`}>
|
<tr key={row.id}>
|
||||||
<td className="font-medium break-all">{row.domain ?? "-"}</td>
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td>{formatNumber(row.backlinks)}</td>
|
<td
|
||||||
<td>{formatNumber(row.referringPages)}</td>
|
key={cell.id}
|
||||||
<td>{formatNumber(row.rank)}</td>
|
className={
|
||||||
<td>{formatDecimal(row.spamScore)}</td>
|
cell.column.id === "domain"
|
||||||
<td>{formatCompactDate(row.firstSeen)}</td>
|
? "font-medium break-all"
|
||||||
<td>
|
: undefined
|
||||||
<div className="text-sm">
|
}
|
||||||
<div>Broken links: {formatNumber(row.brokenBacklinks)}</div>
|
>
|
||||||
<div className="text-base-content/55">
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
Broken pages: {formatNumber(row.brokenPages)}
|
</td>
|
||||||
</div>
|
))}
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||||
import { BacklinksExternalLink } from "./BacklinksPageLinks";
|
import { BacklinksExternalLink } from "./BacklinksPageLinks";
|
||||||
import { TopPagesTableHeader } from "./BacklinksTableHeaders";
|
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
import {
|
|
||||||
DEFAULT_TOP_PAGES_SORT,
|
|
||||||
sortTopPageRows,
|
|
||||||
} from "./backlinksTableSorting";
|
|
||||||
import { formatNumber } from "./backlinksPageUtils";
|
import { formatNumber } from "./backlinksPageUtils";
|
||||||
|
|
||||||
|
type TopPageRow = BacklinksOverviewData["topPages"][number];
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TopPageRow>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("page", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Page"
|
||||||
|
helpText="Page on the target site receiving backlinks."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const page = getValue();
|
||||||
|
return page ? (
|
||||||
|
<BacklinksExternalLink
|
||||||
|
url={page}
|
||||||
|
label={page}
|
||||||
|
className="link link-hover break-all inline-flex items-center gap-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: stringNullsLast,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("backlinks", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Backlinks"
|
||||||
|
helpText="Total backlinks pointing to this page."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("referringDomains", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Referring Domains"
|
||||||
|
helpText="Unique domains linking to this page."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("rank", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Rank"
|
||||||
|
helpText="Authority score for this target page."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("brokenBacklinks", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Broken Backlinks"
|
||||||
|
helpText="Backlinks pointing here that are currently broken."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
sortingFn: numericNullsLast,
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
|
||||||
|
|
||||||
export function TopPagesTable({
|
export function TopPagesTable({
|
||||||
rows,
|
rows,
|
||||||
}: {
|
}: {
|
||||||
rows: BacklinksOverviewData["topPages"];
|
rows: BacklinksOverviewData["topPages"];
|
||||||
}) {
|
}) {
|
||||||
const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT);
|
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||||
const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]);
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <EmptyTableState label="No top pages match this filter." />;
|
return <EmptyTableState label="No top pages match this filter." />;
|
||||||
@ -24,25 +119,33 @@ export function TopPagesTable({
|
|||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="table table-sm">
|
<table className="table table-sm">
|
||||||
<TopPagesTableHeader sort={sort} onSortChange={setSort} />
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedRows.map((row, index) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr key={`${row.page ?? "page"}-${index}`}>
|
<tr key={row.id}>
|
||||||
<td className="min-w-80">
|
{row.getVisibleCells().map((cell) => (
|
||||||
{row.page ? (
|
<td
|
||||||
<BacklinksExternalLink
|
key={cell.id}
|
||||||
url={row.page}
|
className={cell.column.id === "page" ? "min-w-80" : undefined}
|
||||||
label={row.page}
|
>
|
||||||
className="link link-hover break-all inline-flex items-center gap-1"
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
/>
|
</td>
|
||||||
) : (
|
))}
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{formatNumber(row.backlinks)}</td>
|
|
||||||
<td>{formatNumber(row.referringDomains)}</td>
|
|
||||||
<td>{formatNumber(row.rank)}</td>
|
|
||||||
<td>{formatNumber(row.brokenBacklinks)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -12,8 +12,10 @@ function passesNumericFilter(
|
|||||||
max: string,
|
max: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (value == null) return true;
|
if (value == null) return true;
|
||||||
if (min && value < Number(min)) return false;
|
const minN = Number(min);
|
||||||
if (max && value > Number(max)) return false;
|
if (min && !Number.isNaN(minN) && value < minN) return false;
|
||||||
|
const maxN = Number(max);
|
||||||
|
if (max && !Number.isNaN(maxN) && value > maxN) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 bg-base-100 border border-base-300">
|
||||||
<div className="card-body gap-4">
|
<div className="card-body gap-4">
|
||||||
<form
|
<form
|
||||||
className="grid grid-cols-1 gap-3 lg:grid-cols-12"
|
className="flex flex-col gap-3 lg:flex-row lg:items-center"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<controlsForm.Field name="domain">
|
<controlsForm.Field name="domain">
|
||||||
@ -34,10 +34,11 @@ export function DomainSearchCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`input input-bordered lg:col-span-6 flex items-center gap-2 ${domainError ? "input-error" : ""}`}
|
className={`input input-bordered flex flex-1 items-center gap-2 ${domainError ? "input-error" : ""}`}
|
||||||
>
|
>
|
||||||
<Search className="size-4 text-base-content/60" />
|
<Search className="size-4 text-base-content/60" />
|
||||||
<input
|
<input
|
||||||
|
className="grow"
|
||||||
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
|
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
@ -54,7 +55,7 @@ export function DomainSearchCard({
|
|||||||
<controlsForm.Field name="locationCode">
|
<controlsForm.Field name="locationCode">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered lg:col-span-2"
|
className="select select-bordered shrink-0"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = Number(event.target.value);
|
const next = Number(event.target.value);
|
||||||
@ -74,7 +75,7 @@ export function DomainSearchCard({
|
|||||||
<controlsForm.Field name="sort">
|
<controlsForm.Field name="sort">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered lg:col-span-2"
|
className="select select-bordered shrink-0"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = toSortMode(event.target.value) ?? "rank";
|
const next = toSortMode(event.target.value) ?? "rank";
|
||||||
@ -95,7 +96,7 @@ export function DomainSearchCard({
|
|||||||
{(isSubmitting) => (
|
{(isSubmitting) => (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary lg:col-span-2"
|
className="btn btn-primary shrink-0 px-6"
|
||||||
disabled={isLoading || isSubmitting}
|
disabled={isLoading || isSubmitting}
|
||||||
>
|
>
|
||||||
{isLoading || isSubmitting ? "Loading..." : "Search"}
|
{isLoading || isSubmitting ? "Loading..." : "Search"}
|
||||||
|
|||||||
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 (
|
return (
|
||||||
<div key={entry.label} className="dropdown">
|
<div key={entry.label} className="dropdown dropdown-hover">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -221,7 +221,7 @@ function TopNav({
|
|||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="dropdown-content z-20 menu mt-1 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
className="dropdown-content z-20 menu w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
{entry.items.map((item) => {
|
{entry.items.map((item) => {
|
||||||
const { icon: Icon, matchSegment, ...linkProps } = item;
|
const { icon: Icon, matchSegment, ...linkProps } = item;
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Globe,
|
Globe,
|
||||||
Link2,
|
Link2,
|
||||||
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { linkOptions } from "@tanstack/react-router";
|
import { linkOptions } from "@tanstack/react-router";
|
||||||
@ -46,9 +48,21 @@ const projectNavItems = [
|
|||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
matchSegment: "/audit",
|
matchSegment: "/audit",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/p/$projectId/brand-lookup" as const,
|
||||||
|
label: "Brand Lookup",
|
||||||
|
icon: Sparkles,
|
||||||
|
matchSegment: "/brand-lookup",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/p/$projectId/prompt-explorer" as const,
|
||||||
|
label: "Prompt Explorer",
|
||||||
|
icon: MessageSquare,
|
||||||
|
matchSegment: "/prompt-explorer",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/p/$projectId/ai" as const,
|
to: "/p/$projectId/ai" as const,
|
||||||
label: "AI",
|
label: "AI & Agents",
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
matchSegment: "/ai",
|
matchSegment: "/ai",
|
||||||
},
|
},
|
||||||
@ -91,6 +105,13 @@ export function getProjectNavGroups(projectId: string) {
|
|||||||
bySegment("/audit"),
|
bySegment("/audit"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "group" as const,
|
||||||
|
label: "AI Visibility",
|
||||||
|
icon: Sparkles,
|
||||||
|
matchSegments: ["/brand-lookup", "/prompt-explorer"],
|
||||||
|
items: [bySegment("/brand-lookup"), bySegment("/prompt-explorer")],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "standalone" as const,
|
type: "standalone" as const,
|
||||||
item: bySegment("/ai"),
|
item: bySegment("/ai"),
|
||||||
|
|||||||
@ -108,7 +108,9 @@ select {
|
|||||||
|
|
||||||
/* Global input focus styling - use primary color instead of default ring */
|
/* Global input focus styling - use primary color instead of default ring */
|
||||||
.input:focus,
|
.input:focus,
|
||||||
.input:focus-visible {
|
.input:focus-visible,
|
||||||
|
.textarea:focus,
|
||||||
|
.textarea:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||||
|
|||||||
@ -29,8 +29,10 @@ import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/
|
|||||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||||
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
||||||
|
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
||||||
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||||
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
||||||
|
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
||||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||||
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
||||||
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
||||||
@ -136,6 +138,12 @@ const ProjectPProjectIdRankTrackingRoute =
|
|||||||
path: '/rank-tracking',
|
path: '/rank-tracking',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectPProjectIdPromptExplorerRoute =
|
||||||
|
ProjectPProjectIdPromptExplorerRouteImport.update({
|
||||||
|
id: '/prompt-explorer',
|
||||||
|
path: '/prompt-explorer',
|
||||||
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdKeywordsRoute =
|
const ProjectPProjectIdKeywordsRoute =
|
||||||
ProjectPProjectIdKeywordsRouteImport.update({
|
ProjectPProjectIdKeywordsRouteImport.update({
|
||||||
id: '/keywords',
|
id: '/keywords',
|
||||||
@ -147,6 +155,12 @@ const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
|||||||
path: '/domain',
|
path: '/domain',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectPProjectIdBrandLookupRoute =
|
||||||
|
ProjectPProjectIdBrandLookupRouteImport.update({
|
||||||
|
id: '/brand-lookup',
|
||||||
|
path: '/brand-lookup',
|
||||||
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdBacklinksRoute =
|
const ProjectPProjectIdBacklinksRoute =
|
||||||
ProjectPProjectIdBacklinksRouteImport.update({
|
ProjectPProjectIdBacklinksRouteImport.update({
|
||||||
id: '/backlinks',
|
id: '/backlinks',
|
||||||
@ -205,8 +219,10 @@ export interface FileRoutesByFullPath {
|
|||||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||||
@ -230,8 +246,10 @@ export interface FileRoutesByTo {
|
|||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||||
@ -261,8 +279,10 @@ export interface FileRoutesById {
|
|||||||
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
|
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||||
@ -290,8 +310,10 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/ai'
|
| '/p/$projectId/ai'
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
|
| '/p/$projectId/prompt-explorer'
|
||||||
| '/p/$projectId/rank-tracking'
|
| '/p/$projectId/rank-tracking'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
| '/p/$projectId/'
|
| '/p/$projectId/'
|
||||||
@ -315,8 +337,10 @@ export interface FileRouteTypes {
|
|||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
| '/p/$projectId/ai'
|
| '/p/$projectId/ai'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
|
| '/p/$projectId/prompt-explorer'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
| '/p/$projectId/rank-tracking/$configId'
|
| '/p/$projectId/rank-tracking/$configId'
|
||||||
@ -345,8 +369,10 @@ export interface FileRouteTypes {
|
|||||||
| '/_project/p/$projectId/ai'
|
| '/_project/p/$projectId/ai'
|
||||||
| '/_project/p/$projectId/audit'
|
| '/_project/p/$projectId/audit'
|
||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
|
| '/_project/p/$projectId/brand-lookup'
|
||||||
| '/_project/p/$projectId/domain'
|
| '/_project/p/$projectId/domain'
|
||||||
| '/_project/p/$projectId/keywords'
|
| '/_project/p/$projectId/keywords'
|
||||||
|
| '/_project/p/$projectId/prompt-explorer'
|
||||||
| '/_project/p/$projectId/rank-tracking'
|
| '/_project/p/$projectId/rank-tracking'
|
||||||
| '/_project/p/$projectId/saved'
|
| '/_project/p/$projectId/saved'
|
||||||
| '/_project/p/$projectId/'
|
| '/_project/p/$projectId/'
|
||||||
@ -510,6 +536,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_project/p/$projectId/prompt-explorer': {
|
||||||
|
id: '/_project/p/$projectId/prompt-explorer'
|
||||||
|
path: '/prompt-explorer'
|
||||||
|
fullPath: '/p/$projectId/prompt-explorer'
|
||||||
|
preLoaderRoute: typeof ProjectPProjectIdPromptExplorerRouteImport
|
||||||
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
|
}
|
||||||
'/_project/p/$projectId/keywords': {
|
'/_project/p/$projectId/keywords': {
|
||||||
id: '/_project/p/$projectId/keywords'
|
id: '/_project/p/$projectId/keywords'
|
||||||
path: '/keywords'
|
path: '/keywords'
|
||||||
@ -524,6 +557,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdDomainRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdDomainRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_project/p/$projectId/brand-lookup': {
|
||||||
|
id: '/_project/p/$projectId/brand-lookup'
|
||||||
|
path: '/brand-lookup'
|
||||||
|
fullPath: '/p/$projectId/brand-lookup'
|
||||||
|
preLoaderRoute: typeof ProjectPProjectIdBrandLookupRouteImport
|
||||||
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
|
}
|
||||||
'/_project/p/$projectId/backlinks': {
|
'/_project/p/$projectId/backlinks': {
|
||||||
id: '/_project/p/$projectId/backlinks'
|
id: '/_project/p/$projectId/backlinks'
|
||||||
path: '/backlinks'
|
path: '/backlinks'
|
||||||
@ -633,8 +673,10 @@ interface ProjectPProjectIdRouteRouteChildren {
|
|||||||
ProjectPProjectIdAiRoute: typeof ProjectPProjectIdAiRoute
|
ProjectPProjectIdAiRoute: typeof ProjectPProjectIdAiRoute
|
||||||
ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren
|
ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||||
|
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
||||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||||
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
||||||
@ -645,8 +687,10 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
|||||||
ProjectPProjectIdAiRoute: ProjectPProjectIdAiRoute,
|
ProjectPProjectIdAiRoute: ProjectPProjectIdAiRoute,
|
||||||
ProjectPProjectIdAuditRoute: ProjectPProjectIdAuditRouteWithChildren,
|
ProjectPProjectIdAuditRoute: ProjectPProjectIdAuditRouteWithChildren,
|
||||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||||
|
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
||||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||||
|
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
||||||
ProjectPProjectIdRankTrackingRoute:
|
ProjectPProjectIdRankTrackingRoute:
|
||||||
ProjectPProjectIdRankTrackingRouteWithChildren,
|
ProjectPProjectIdRankTrackingRouteWithChildren,
|
||||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||||
|
|||||||
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(),
|
fetchReferringDomainsRaw: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/lib/dataforseoLlm", () => ({
|
||||||
|
fetchLlmResponseRaw: vi.fn(),
|
||||||
|
fetchLlmAggregatedMetricsRaw: vi.fn(),
|
||||||
|
fetchLlmMentionsSearchRaw: vi.fn(),
|
||||||
|
fetchLlmTopPagesRaw: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createDataforseoClient,
|
createDataforseoClient,
|
||||||
mapDataforseoPathToCreditFeature,
|
mapDataforseoPathToCreditFeature,
|
||||||
@ -326,4 +333,34 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
|||||||
]),
|
]),
|
||||||
).toBe("site_audit");
|
).toBe("site_audit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps real ai_optimization paths to ai_search", () => {
|
||||||
|
expect(
|
||||||
|
mapDataforseoPathToCreditFeature([
|
||||||
|
"v3",
|
||||||
|
"ai_optimization",
|
||||||
|
"llm_mentions",
|
||||||
|
"search",
|
||||||
|
"live",
|
||||||
|
]),
|
||||||
|
).toBe("ai_search");
|
||||||
|
expect(
|
||||||
|
mapDataforseoPathToCreditFeature([
|
||||||
|
"v3",
|
||||||
|
"ai_optimization",
|
||||||
|
"llm_mentions",
|
||||||
|
"aggregated_metrics",
|
||||||
|
"live",
|
||||||
|
]),
|
||||||
|
).toBe("ai_search");
|
||||||
|
expect(
|
||||||
|
mapDataforseoPathToCreditFeature([
|
||||||
|
"v3",
|
||||||
|
"ai_optimization",
|
||||||
|
"claude",
|
||||||
|
"llm_responses",
|
||||||
|
"live",
|
||||||
|
]),
|
||||||
|
).toBe("ai_search");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,6 +20,16 @@ import {
|
|||||||
type LabsKeywordDataItem,
|
type LabsKeywordDataItem,
|
||||||
type SerpLiveItem,
|
type SerpLiveItem,
|
||||||
} from "@/server/lib/dataforseo";
|
} from "@/server/lib/dataforseo";
|
||||||
|
import {
|
||||||
|
fetchLlmAggregatedMetricsRaw,
|
||||||
|
fetchLlmMentionsSearchRaw,
|
||||||
|
fetchLlmResponseRaw,
|
||||||
|
fetchLlmTopPagesRaw,
|
||||||
|
type LlmAggregatedMetricsInput,
|
||||||
|
type LlmMentionsSearchInput,
|
||||||
|
type LlmResponsesInput,
|
||||||
|
type LlmTopPagesInput,
|
||||||
|
} from "@/server/lib/dataforseoLlm";
|
||||||
import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
|
import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
|
||||||
import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
|
import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
|
||||||
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
|
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
|
||||||
@ -46,7 +56,8 @@ type CreditFeature =
|
|||||||
| "domain_overview"
|
| "domain_overview"
|
||||||
| "backlinks"
|
| "backlinks"
|
||||||
| "site_audit"
|
| "site_audit"
|
||||||
| "rank_tracking";
|
| "rank_tracking"
|
||||||
|
| "ai_search";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
||||||
@ -65,6 +76,8 @@ export function mapDataforseoPathToCreditFeature(
|
|||||||
return "backlinks";
|
return "backlinks";
|
||||||
case "serp":
|
case "serp":
|
||||||
return "keyword_research";
|
return "keyword_research";
|
||||||
|
case "ai_optimization":
|
||||||
|
return "ai_search";
|
||||||
case "dataforseo_labs": {
|
case "dataforseo_labs": {
|
||||||
const endpoint = path[3] ?? "";
|
const endpoint = path[3] ?? "";
|
||||||
if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") {
|
if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") {
|
||||||
@ -242,6 +255,24 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
aiSearch: {
|
||||||
|
mentionsSearch(input: LlmMentionsSearchInput) {
|
||||||
|
return meterDataforseoCall(customer, () =>
|
||||||
|
fetchLlmMentionsSearchRaw(input),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
aggregatedMetrics(input: LlmAggregatedMetricsInput) {
|
||||||
|
return meterDataforseoCall(customer, () =>
|
||||||
|
fetchLlmAggregatedMetricsRaw(input),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
topPages(input: LlmTopPagesInput) {
|
||||||
|
return meterDataforseoCall(customer, () => fetchLlmTopPagesRaw(input));
|
||||||
|
},
|
||||||
|
llmResponse(input: LlmResponsesInput) {
|
||||||
|
return meterDataforseoCall(customer, () => fetchLlmResponseRaw(input));
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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) {
|
export function autumnSeoDataCreditsToUsd(credits: number) {
|
||||||
return credits / AUTUMN_SEO_DATA_CREDITS_PER_USD;
|
return credits / AUTUMN_SEO_DATA_CREDITS_PER_USD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a raw DataForSEO USD cost into the USD amount a hosted customer is
|
||||||
|
* actually billed, applying the platform markup. Use this when displaying
|
||||||
|
* cost estimates so the number matches what the user will be charged.
|
||||||
|
*
|
||||||
|
* Self-hosted deployments pay DataForSEO directly at the raw rate and should
|
||||||
|
* show the raw number — gate at the call site with `isHostedClientAuthMode`.
|
||||||
|
*/
|
||||||
|
export function applyBillingMarkupUsd(rawUsd: number): number {
|
||||||
|
return roundUsdForBilling(rawUsd * SEO_DATA_COST_MARKUP);
|
||||||
|
}
|
||||||
|
|||||||
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