Add per-feature usage breakdown to billing page (#232)
This commit is contained in:
parent
e93e30e19e
commit
06e15dc70f
98
src/client/features/billing/BillingFeatureBreakdown.test.ts
Normal file
98
src/client/features/billing/BillingFeatureBreakdown.test.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/serverFunctions/billing", () => ({
|
||||
getBillingUsageEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getBillingFeatureBreakdownRows } from "./BillingFeatureBreakdown";
|
||||
|
||||
describe("getBillingFeatureBreakdownRows", () => {
|
||||
it("uses explicit creditFeature when present", () => {
|
||||
const rows = getBillingFeatureBreakdownRows([
|
||||
{
|
||||
value: 250,
|
||||
properties: {
|
||||
creditFeature: "rank_tracking",
|
||||
paths: ["v3/serp/google/organic/live/regular"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([{ label: "Rank Tracking", usd: 0.25 }]);
|
||||
});
|
||||
|
||||
it("supports raw Autumn property aliases", () => {
|
||||
const rows = getBillingFeatureBreakdownRows([
|
||||
{
|
||||
value: 200,
|
||||
properties: {
|
||||
credit_feature: "local_seo",
|
||||
path: "v3/backlinks/summary/live",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([{ label: "Local SEO", usd: 0.2 }]);
|
||||
});
|
||||
|
||||
it("infers legacy events from DataForSEO paths", () => {
|
||||
const rows = getBillingFeatureBreakdownRows([
|
||||
{
|
||||
value: 500,
|
||||
properties: { paths: ["v3/backlinks/summary/live"] },
|
||||
},
|
||||
{
|
||||
value: 250,
|
||||
properties: {
|
||||
paths: ["v3/dataforseo_labs/google/domain_rank_overview/live"],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 125,
|
||||
properties: {
|
||||
paths: ["v3/ai_optimization/llm_mentions/search/live"],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 100,
|
||||
properties: { paths: ["backlinks/summary"] },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ label: "Backlinks", usd: 0.6 },
|
||||
{ label: "Domain Overview", usd: 0.25 },
|
||||
{ label: "AI Citations", usd: 0.125 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports legacy JSON-encoded path groups", () => {
|
||||
const rows = getBillingFeatureBreakdownRows([
|
||||
{
|
||||
value: 300,
|
||||
properties: {
|
||||
paths: '["v3/ai_optimization/chat_gpt/llm_responses/live"]',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 200,
|
||||
properties: {
|
||||
paths: '["v3","ai_optimization","perplexity","llm_responses","live"]',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([{ label: "AI Prompt Responses", usd: 0.5 }]);
|
||||
});
|
||||
|
||||
it("falls back to Other when neither feature nor path is available", () => {
|
||||
const rows = getBillingFeatureBreakdownRows([
|
||||
{
|
||||
value: 100,
|
||||
properties: {},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([{ label: "Other", usd: 0.1 }]);
|
||||
});
|
||||
});
|
||||
185
src/client/features/billing/BillingFeatureBreakdown.tsx
Normal file
185
src/client/features/billing/BillingFeatureBreakdown.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
autumnSeoDataCreditsToUsd,
|
||||
} from "@/shared/billing";
|
||||
import {
|
||||
creditFeatureLabel,
|
||||
mapDataforseoPathToCreditFeature,
|
||||
} from "@/shared/billing-credit-features";
|
||||
import {
|
||||
getBillingUsageEvents,
|
||||
type BillingUsageEvent,
|
||||
} from "@/serverFunctions/billing";
|
||||
|
||||
const BILLING_USAGE_FEATURE_IDS: string[] = [
|
||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
];
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type BillingUsageEventProperties = {
|
||||
creditFeature?: unknown;
|
||||
credit_feature?: unknown;
|
||||
path?: unknown;
|
||||
paths?: unknown;
|
||||
};
|
||||
|
||||
type BillingFeatureBreakdownRow = {
|
||||
label: string;
|
||||
usd: number;
|
||||
};
|
||||
|
||||
type BillingUsageRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
function getLast30DayUsageRange(): BillingUsageRange {
|
||||
const end = Date.now();
|
||||
return {
|
||||
start: end - THIRTY_DAYS_MS,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getPathSegmentsFromProperties(
|
||||
properties: BillingUsageEventProperties,
|
||||
): string[] | null {
|
||||
const paths = properties.paths ?? properties.path;
|
||||
if (Array.isArray(paths)) {
|
||||
const stringPaths = paths.filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
);
|
||||
if (
|
||||
stringPaths.length > 1 &&
|
||||
stringPaths.every((segment) => !segment.includes("/"))
|
||||
) {
|
||||
return stringPaths;
|
||||
}
|
||||
|
||||
const path = stringPaths[0];
|
||||
if (!path) return null;
|
||||
|
||||
const parsedPath = parseJsonEncodedPath(path);
|
||||
return parsedPath ?? path.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof paths !== "string") return null;
|
||||
|
||||
const parsedPath = parseJsonEncodedPath(paths);
|
||||
return parsedPath ?? paths.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function parseJsonEncodedPath(path: string): string[] | null {
|
||||
if (!path.startsWith("[")) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(path);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const stringPaths = parsed.filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
);
|
||||
if (
|
||||
stringPaths.length > 1 &&
|
||||
stringPaths.every((segment) => !segment.includes("/"))
|
||||
) {
|
||||
return stringPaths;
|
||||
}
|
||||
|
||||
const firstPath = stringPaths[0];
|
||||
return firstPath ? firstPath.split("/").filter(Boolean) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCreditFeatureFromUsageEvent(
|
||||
event: BillingUsageEvent,
|
||||
): string | null {
|
||||
const properties = isRecord(event.properties) ? event.properties : {};
|
||||
const explicitFeature = properties.creditFeature ?? properties.credit_feature;
|
||||
if (typeof explicitFeature === "string" && explicitFeature.length > 0) {
|
||||
return explicitFeature;
|
||||
}
|
||||
|
||||
const path = getPathSegmentsFromProperties(properties);
|
||||
return path ? mapDataforseoPathToCreditFeature(path) : null;
|
||||
}
|
||||
|
||||
export function getBillingFeatureBreakdownRows(
|
||||
events: BillingUsageEvent[],
|
||||
): BillingFeatureBreakdownRow[] {
|
||||
const creditsByLabel = new Map<string, number>();
|
||||
|
||||
for (const event of events) {
|
||||
const feature = getCreditFeatureFromUsageEvent(event);
|
||||
const label = feature ? creditFeatureLabel(feature) : "Other";
|
||||
creditsByLabel.set(label, (creditsByLabel.get(label) ?? 0) + event.value);
|
||||
}
|
||||
|
||||
return [...creditsByLabel.entries()]
|
||||
.map(([label, credits]) => ({
|
||||
label,
|
||||
usd: autumnSeoDataCreditsToUsd(credits),
|
||||
}))
|
||||
.filter((row) => row.usd > 0)
|
||||
.toSorted((a, b) => b.usd - a.usd);
|
||||
}
|
||||
|
||||
export function BillingFeatureBreakdown() {
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["billing", "usage-events", BILLING_USAGE_FEATURE_IDS, "30d"],
|
||||
queryFn: () => getBillingUsageEvents({ data: getLast30DayUsageRange() }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const rows = getBillingFeatureBreakdownRows(eventsQuery.data ?? []);
|
||||
const total = rows.reduce((sum, row) => sum + row.usd, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<span className="font-semibold">Usage by feature</span>
|
||||
<span className="text-xs text-base-content/50">Last 30 days</span>
|
||||
</div>
|
||||
|
||||
{eventsQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-base-content/40">
|
||||
No usage recorded yet
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => (
|
||||
<li key={row.label} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-4 text-sm">
|
||||
<span>{row.label}</span>
|
||||
<span className="tabular-nums text-base-content/70">
|
||||
${row.usd.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-base-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#7c3aed]"
|
||||
style={{ width: `${(row.usd / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
|
||||
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
|
||||
import { BillingFeatureBreakdown } from "@/client/features/billing/BillingFeatureBreakdown";
|
||||
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
|
||||
import { getBillingRouteState } from "@/client/features/billing/route-state";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
@ -310,6 +311,9 @@ function BillingPageContent() {
|
||||
{/* Usage chart */}
|
||||
<BillingUsageChart />
|
||||
|
||||
{/* Per-feature usage breakdown */}
|
||||
<BillingFeatureBreakdown />
|
||||
|
||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||
|
||||
<p className="text-xs text-base-content/40">
|
||||
|
||||
@ -354,6 +354,9 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]),
|
||||
).toBe("backlinks");
|
||||
expect(mapDataforseoPathToCreditFeature(["backlinks", "summary"])).toBe(
|
||||
"backlinks",
|
||||
);
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature([
|
||||
"v3",
|
||||
@ -376,7 +379,7 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
||||
).toBe("site_audit");
|
||||
});
|
||||
|
||||
it("maps real ai_optimization paths to ai_search", () => {
|
||||
it("maps ai_optimization llm_mentions paths to ai_citations", () => {
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature([
|
||||
"v3",
|
||||
@ -385,7 +388,7 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
||||
"search",
|
||||
"live",
|
||||
]),
|
||||
).toBe("ai_search");
|
||||
).toBe("ai_citations");
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature([
|
||||
"v3",
|
||||
@ -394,16 +397,30 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
||||
"aggregated_metrics",
|
||||
"live",
|
||||
]),
|
||||
).toBe("ai_search");
|
||||
).toBe("ai_citations");
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature([
|
||||
"v3",
|
||||
"ai_optimization",
|
||||
"claude",
|
||||
"llm_responses",
|
||||
"llm_mentions",
|
||||
"top_pages",
|
||||
"live",
|
||||
]),
|
||||
).toBe("ai_search");
|
||||
).toBe("ai_citations");
|
||||
});
|
||||
|
||||
it("maps ai_optimization provider llm_responses paths to ai_prompt_responses", () => {
|
||||
for (const provider of ["chat_gpt", "claude", "gemini", "perplexity"]) {
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature([
|
||||
"v3",
|
||||
"ai_optimization",
|
||||
provider,
|
||||
"llm_responses",
|
||||
"live",
|
||||
]),
|
||||
).toBe("ai_prompt_responses");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps local and supporting paths to the intended credit features", () => {
|
||||
|
||||
@ -6,6 +6,10 @@ import {
|
||||
SEO_DATA_COST_MARKUP,
|
||||
roundUsdForBilling,
|
||||
} from "@/shared/billing";
|
||||
import {
|
||||
type CreditFeature,
|
||||
mapDataforseoPathToCreditFeature,
|
||||
} from "@/shared/billing-credit-features";
|
||||
import { autumn } from "@/server/billing/autumn";
|
||||
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
@ -60,55 +64,7 @@ import { AppError } from "@/server/lib/errors";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
|
||||
type CreditFeature =
|
||||
| "keyword_research"
|
||||
| "domain_overview"
|
||||
| "backlinks"
|
||||
| "site_audit"
|
||||
| "rank_tracking"
|
||||
| "ai_search"
|
||||
| "local_seo";
|
||||
|
||||
/**
|
||||
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
||||
* to a product feature for analytics. path[1] is the API module; for dataforseo_labs,
|
||||
* path[3] distinguishes keyword vs domain endpoints.
|
||||
*/
|
||||
export function mapDataforseoPathToCreditFeature(
|
||||
path: string[],
|
||||
): CreditFeature {
|
||||
const module = path[1];
|
||||
|
||||
switch (module) {
|
||||
case "on_page":
|
||||
return "site_audit";
|
||||
case "backlinks":
|
||||
return "backlinks";
|
||||
case "serp":
|
||||
return path[2] === "google" && ["maps", "local_finder"].includes(path[3])
|
||||
? "local_seo"
|
||||
: "keyword_research";
|
||||
case "ai_optimization":
|
||||
return "ai_search";
|
||||
case "business_data":
|
||||
return "local_seo";
|
||||
case "keywords_data":
|
||||
return "keyword_research";
|
||||
case "dataforseo_labs": {
|
||||
const endpoint = path[3] ?? "";
|
||||
if (
|
||||
endpoint.startsWith("domain_") ||
|
||||
endpoint === "ranked_keywords" ||
|
||||
endpoint === "relevant_pages"
|
||||
) {
|
||||
return "domain_overview";
|
||||
}
|
||||
return "keyword_research";
|
||||
}
|
||||
default:
|
||||
return "site_audit";
|
||||
}
|
||||
}
|
||||
export { mapDataforseoPathToCreditFeature };
|
||||
|
||||
export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||
return {
|
||||
@ -457,10 +413,14 @@ async function trackDataforseoCost(args: {
|
||||
const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits);
|
||||
const topupDeduct = totalCostCredits - monthlyDeduct;
|
||||
|
||||
const creditFeature =
|
||||
args.creditFeature ?? mapDataforseoPathToCreditFeature(args.billing.path);
|
||||
|
||||
const properties = {
|
||||
provider: "dataforseo",
|
||||
currency: "USD",
|
||||
paths: [args.billing.path.join("/")],
|
||||
creditFeature,
|
||||
totalCostUsd,
|
||||
totalCostCredits,
|
||||
fromCache: false,
|
||||
@ -497,9 +457,7 @@ async function trackDataforseoCost(args: {
|
||||
organizationId: args.customer.organizationId,
|
||||
properties: {
|
||||
project_id: args.customer.projectId,
|
||||
credit_feature:
|
||||
args.creditFeature ??
|
||||
mapDataforseoPathToCreditFeature(args.billing.path),
|
||||
credit_feature: creditFeature,
|
||||
monthly_credits: monthlyDeduct,
|
||||
topup_credits: topupDeduct,
|
||||
total_credits: totalCostCredits,
|
||||
|
||||
122
src/serverFunctions/billing.ts
Normal file
122
src/serverFunctions/billing.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
} from "@/shared/billing";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import {
|
||||
getRequiredEnvValue,
|
||||
isHostedServerAuthMode,
|
||||
} from "@/server/lib/runtime-env";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
|
||||
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
|
||||
const EVENT_PAGE_LIMIT = 1000;
|
||||
const BILLING_USAGE_FEATURE_IDS = [
|
||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
] as const;
|
||||
|
||||
const billingUsageRangeSchema = z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
const billingUsagePropertySchema = z.json();
|
||||
|
||||
const autumnEventSchema = z
|
||||
.object({
|
||||
value: z.number(),
|
||||
properties: z
|
||||
.record(z.string(), billingUsagePropertySchema)
|
||||
.optional()
|
||||
.default({}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const autumnEventsListResponseSchema = z
|
||||
.object({
|
||||
list: z.array(autumnEventSchema),
|
||||
has_more: z.boolean().optional(),
|
||||
hasMore: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type BillingUsageEvent = {
|
||||
value: number;
|
||||
properties: Record<string, z.infer<typeof billingUsagePropertySchema>>;
|
||||
};
|
||||
|
||||
export const getBillingUsageEvents = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => billingUsageRangeSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
if (!(await isHostedServerAuthMode())) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events: BillingUsageEvent[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (;;) {
|
||||
const page = await fetchAutumnEventsPage({
|
||||
customerId: context.organizationId,
|
||||
end: data.end,
|
||||
offset,
|
||||
start: data.start,
|
||||
});
|
||||
|
||||
events.push(...page.list);
|
||||
|
||||
if (!page.hasMore || page.list.length === 0) {
|
||||
return events;
|
||||
}
|
||||
|
||||
offset += page.list.length;
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchAutumnEventsPage(args: {
|
||||
customerId: string;
|
||||
end: number;
|
||||
offset: number;
|
||||
start: number;
|
||||
}): Promise<{ list: BillingUsageEvent[]; hasMore: boolean }> {
|
||||
const secretKey = await getRequiredEnvValue("AUTUMN_SECRET_KEY");
|
||||
const response = await fetch(AUTUMN_EVENTS_LIST_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_id: args.customerId,
|
||||
custom_range: {
|
||||
end: args.end,
|
||||
start: args.start,
|
||||
},
|
||||
feature_id: BILLING_USAGE_FEATURE_IDS,
|
||||
limit: EVENT_PAGE_LIMIT,
|
||||
offset: args.offset,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
`Autumn events.list failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = autumnEventsListResponseSchema.parse(await response.json());
|
||||
|
||||
return {
|
||||
hasMore: parsed.has_more ?? parsed.hasMore ?? false,
|
||||
list: parsed.list.map((event) => ({
|
||||
value: event.value,
|
||||
properties: event.properties,
|
||||
})),
|
||||
};
|
||||
}
|
||||
73
src/shared/billing-credit-features.ts
Normal file
73
src/shared/billing-credit-features.ts
Normal file
@ -0,0 +1,73 @@
|
||||
export type CreditFeature =
|
||||
| "keyword_research"
|
||||
| "domain_overview"
|
||||
| "backlinks"
|
||||
| "site_audit"
|
||||
| "rank_tracking"
|
||||
| "ai_citations"
|
||||
| "ai_prompt_responses"
|
||||
| "local_seo";
|
||||
|
||||
const CREDIT_FEATURE_LABELS: Record<string, string> = {
|
||||
keyword_research: "Keyword Research",
|
||||
domain_overview: "Domain Overview",
|
||||
backlinks: "Backlinks",
|
||||
site_audit: "Site Audit",
|
||||
rank_tracking: "Rank Tracking",
|
||||
ai_citations: "AI Citations",
|
||||
ai_prompt_responses: "AI Prompt Responses",
|
||||
ai_search: "AI Search",
|
||||
local_seo: "Local SEO",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
||||
* to a product feature for analytics. path[1] is the API module; for dataforseo_labs,
|
||||
* path[3] distinguishes keyword vs domain endpoints.
|
||||
*/
|
||||
export function mapDataforseoPathToCreditFeature(
|
||||
path: readonly string[],
|
||||
): CreditFeature {
|
||||
const normalizedPath = path[0] === "v3" ? path : ["v3", ...path];
|
||||
const module = normalizedPath[1];
|
||||
|
||||
switch (module) {
|
||||
case "on_page":
|
||||
return "site_audit";
|
||||
case "backlinks":
|
||||
return "backlinks";
|
||||
case "serp":
|
||||
return normalizedPath[2] === "google" &&
|
||||
["maps", "local_finder"].includes(normalizedPath[3])
|
||||
? "local_seo"
|
||||
: "keyword_research";
|
||||
case "ai_optimization":
|
||||
// llm_mentions/* are brand-citation lookups; every other ai_optimization
|
||||
// endpoint is a provider /llm_responses prompt response (chat_gpt, claude,
|
||||
// gemini, perplexity).
|
||||
return normalizedPath[2] === "llm_mentions"
|
||||
? "ai_citations"
|
||||
: "ai_prompt_responses";
|
||||
case "business_data":
|
||||
return "local_seo";
|
||||
case "keywords_data":
|
||||
return "keyword_research";
|
||||
case "dataforseo_labs": {
|
||||
const endpoint = normalizedPath[3] ?? "";
|
||||
if (
|
||||
endpoint.startsWith("domain_") ||
|
||||
endpoint === "ranked_keywords" ||
|
||||
endpoint === "relevant_pages"
|
||||
) {
|
||||
return "domain_overview";
|
||||
}
|
||||
return "keyword_research";
|
||||
}
|
||||
default:
|
||||
return "site_audit";
|
||||
}
|
||||
}
|
||||
|
||||
export function creditFeatureLabel(key: string) {
|
||||
return CREDIT_FEATURE_LABELS[key] ?? "Other";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user