Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/client/components/MeteredActionLabel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { meteredActionLabel, meteredEstimateNote } from "./MeteredActionLabel";

describe("meteredActionLabel", () => {
it("names credits when the call always spends", () => {
expect(
meteredActionLabel("Update keyword stats", { kind: "credits" }),
).toBe("Update keyword stats · uses credits");
});

/**
* The softening is the whole point of `cacheAware`. Most of these controls
* read a server cache first, so a second press inside the window spends
* nothing — and "uses credits" there is a lie in the expensive-sounding
* direction, which teaches people to distrust the labels that are accurate.
*/
it("softens to may when a cache could absorb the call", () => {
expect(
meteredActionLabel("Analyze acme.com", { kind: "credits" }, true),
).toBe("Analyze acme.com · may use credits");
});

it("counts requests, singular and plural", () => {
expect(
meteredActionLabel("Fetch reviews", { kind: "paidRequests", count: 1 }),
).toBe("Fetch reviews · 1 paid request");
expect(meteredActionLabel("Run", { kind: "paidRequests", count: 3 })).toBe(
"Run · 3 paid requests",
);
});

it("caps a cache-aware count with up to", () => {
expect(
meteredActionLabel(
"Build brief",
{ kind: "paidRequests", count: 4 },
true,
),
).toBe("Build brief · up to 4 paid requests");
});

it("quotes a measured estimate when the call always spends", () => {
expect(
meteredActionLabel("Look up", { kind: "estimateUsd", usd: 1.088 }),
).toBe("Look up · est. $1.09");
});

/**
* A cached run costs nothing, so the figure moves off the button and into a
* conditional beside it. Putting "$1.09" on a control that may charge zero
* would be the same overclaim in reverse.
*/
it("moves a cache-aware estimate off the button and into a note", () => {
const disclosure = { kind: "estimateUsd", usd: 1.088 } as const;
expect(meteredActionLabel("Re-analyze", disclosure, true)).toBe(
"Re-analyze · may use credits",
);
expect(meteredEstimateNote(disclosure, true)).toBe(
"If not cached: est. $1.09.",
);
});

it("has no note to add when the price is already on the button", () => {
expect(
meteredEstimateNote({ kind: "estimateUsd", usd: 1 }, false),
).toBeNull();
expect(meteredEstimateNote({ kind: "credits" }, true)).toBeNull();
expect(
meteredEstimateNote({ kind: "paidRequests", count: 2 }, true),
).toBeNull();
});
});
64 changes: 64 additions & 0 deletions src/client/components/MeteredActionLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The label on a control that spends money.
*
* This app is careful about not spending WITHOUT a click — `useMeteredQuery`
* disables mount/focus/reconnect refetching so a restored page cannot fire a
* paid request. The gap this closes is the other half: controls that do spend,
* and did not say so before you pressed them.
*
* One component rather than seven hand-written suffixes, because the wording has
* to encode two facts that are easy to get wrong independently:
*
* **How much.** A count of upstream requests when that is knowable, a measured
* dollar figure when one exists, and otherwise just "uses credits". Never an
* invented number — see the header of `shared/analysis-costs.ts`: showing a
* guessed price immediately before spending someone's money is worse than
* showing none.
*
* **Whether it might cost nothing.** Most of these read a server-side cache
* first, so a second press inside the window spends zero. Saying "1 paid
* request" there would be a lie in the expensive-sounding direction, which
* teaches people to distrust the labels that are accurate. `cacheAware` softens
* the claim to "up to" / "may".
*/

type MeteredDisclosure =
| { kind: "credits" }
| { kind: "paidRequests"; count: number }
| { kind: "estimateUsd"; usd: number };

export function meteredActionLabel(
action: string,
disclosure: MeteredDisclosure,
/** The call reads a cache first, so it may cost nothing. */
cacheAware = false,
): string {
switch (disclosure.kind) {
case "credits":
return `${action} · ${cacheAware ? "may use credits" : "uses credits"}`;
case "paidRequests": {
const unit = disclosure.count === 1 ? "paid request" : "paid requests";
return cacheAware
? `${action} · up to ${disclosure.count} ${unit}`
: `${action} · ${disclosure.count} ${unit}`;
}
case "estimateUsd":
// A cached run costs nothing, so the figure is stated as a conditional
// beside the control rather than as the price of pressing it.
return cacheAware
? `${action} · may use credits`
: `${action} · est. $${disclosure.usd.toFixed(2)}`;
}
}

/**
* The conditional price line that accompanies a cache-aware estimate.
* Returns null when there is nothing honest to add.
*/
export function meteredEstimateNote(
disclosure: MeteredDisclosure,
cacheAware = false,
): string | null {
if (disclosure.kind !== "estimateUsd" || !cacheAware) return null;
return `If not cached: est. $${disclosure.usd.toFixed(2)}.`;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import {
meteredActionLabel,
meteredEstimateNote,
} from "@/client/components/MeteredActionLabel";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { applyBillingMarkupUsd } from "@/shared/billing";
import {
BRAND_LOOKUP_COMPETITOR_RAW_COST_USD,
BRAND_LOOKUP_RAW_COST_USD,
} from "@/shared/analysis-costs";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Radar, RefreshCw } from "lucide-react";
Expand All @@ -24,6 +34,18 @@ import {
* stored snapshots. Renders nothing when the project has no domain, so the tab
* falls back to its ad-hoc search + recent-searches default.
*/

// The project analysis runs the same Brand Lookup fan-out, so it costs what that
// costs -- reuse the MEASURED constants rather than inventing a second figure.
// Hosted customers pay the marked-up price; self-hosted users pay DataForSEO
// directly, which is what `applyBillingMarkupUsd` is gated on.
const BRAND_ANALYSIS_DISPLAYED_COST_USD = isHostedClientAuthMode()
? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD)
: BRAND_LOOKUP_RAW_COST_USD;
const BRAND_COMPETITOR_DISPLAYED_COST_USD = isHostedClientAuthMode()
? applyBillingMarkupUsd(BRAND_LOOKUP_COMPETITOR_RAW_COST_USD)
: BRAND_LOOKUP_COMPETITOR_RAW_COST_USD;

export function ProjectVisibilityPanel({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [competitorsInput, setCompetitorsInput] = useState("");
Expand Down Expand Up @@ -103,7 +125,11 @@ export function ProjectVisibilityPanel({ projectId }: { projectId: string }) {
) : (
<RefreshCw className="size-4" />
)}
{latest ? "Re-analyze" : `Analyze ${domain}`}
{meteredActionLabel(
latest ? "Re-analyze" : `Analyze ${domain}`,
{ kind: "estimateUsd", usd: BRAND_ANALYSIS_DISPLAYED_COST_USD },
true,
)}
</button>
</div>

Expand All @@ -119,6 +145,21 @@ export function ProjectVisibilityPanel({ projectId }: { projectId: string }) {
/>
</label>

{/* The price lives here rather than on the button because the run is
server-cached: a repeat inside the window spends nothing, so quoting a
figure ON the control would overstate what pressing it costs. The
competitor line is conditional because those two extra cross-platform
calls only happen when the field has entries. */}
<p className="text-xs text-base-content/50">
{meteredEstimateNote(
{ kind: "estimateUsd", usd: BRAND_ANALYSIS_DISPLAYED_COST_USD },
true,
)}
{parseCompetitorList(competitorsInput).length > 0
? ` Plus ~$${BRAND_COMPETITOR_DISPLAYED_COST_USD.toFixed(2)} to compare competitors.`
: null}
</p>

{historyQuery.isPending && Boolean(domain) ? (
<div className="flex items-center gap-2 py-4 text-sm text-base-content/60">
<Loader2 className="size-4 animate-spin" /> Loading tracked
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { meteredActionLabel } from "@/client/components/MeteredActionLabel";
import type { FormEvent } from "react";
import {
formatCountryLabel,
Expand Down Expand Up @@ -202,7 +203,12 @@ export function PromptExplorerForm({
className="btn btn-primary shrink-0 px-6"
disabled={isLoading || form.models.length === 0}
>
{isLoading ? "Running…" : "Run"}
{isLoading
? "Running…"
: meteredActionLabel("Run", {
kind: "paidRequests",
count: new Set(form.models).size,
})}
</button>
</div>

Expand Down
7 changes: 6 additions & 1 deletion src/client/features/content/ContentOptimizerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useQueries } from "@tanstack/react-query";
import { NotebookPen, Search } from "lucide-react";
import { meteredActionLabel } from "@/client/components/MeteredActionLabel";
import { BriefTargets, quantile } from "@/client/features/content/BriefTargets";
import { ContentEmptyState } from "@/client/features/content/ContentEmptyState";
import { useContentBriefHistory } from "@/client/features/content/useContentBriefHistory";
Expand Down Expand Up @@ -186,7 +187,11 @@ function BuildBriefButton({
) : (
<Search className="size-3.5" />
)}
Build brief
{meteredActionLabel(
"Build brief",
{ kind: "paidRequests", count: 4 },
true,
)}
</button>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion src/client/features/local-seo/LocalReviewsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getBusinessReviewsResult,
startBusinessReviews,
} from "@/serverFunctions/local-seo";
import { meteredActionLabel } from "@/client/components/MeteredActionLabel";
import { ReviewAnalyticsCards } from "./ReviewAnalyticsCards";
import {
clearReviewsTask,
Expand Down Expand Up @@ -128,7 +129,10 @@ export function LocalReviewsSection({
Crawling reviews…
</>
) : (
"Fetch reviews"
meteredActionLabel("Fetch reviews", {
kind: "paidRequests",
count: 1,
})
)}
</button>
</div>
Expand Down
7 changes: 6 additions & 1 deletion src/client/features/local-seo/LocalSeoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { GbpAuditCard } from "./GbpAuditCard";
import { GbpWriteSection } from "./GbpWriteSection";
import { LocalReviewsSection } from "./LocalReviewsSection";
import { CitationTrackerSection } from "@/client/features/citations/CitationTrackerSection";
import { meteredActionLabel } from "@/client/components/MeteredActionLabel";
import { AppPageShell } from "@/client/components/AppPageShell";
import { resolveQueryState } from "@/client/components/state/queryState";
import { QueryStateBoundary } from "@/client/components/state/QueryStateBoundary";
Expand Down Expand Up @@ -207,7 +208,11 @@ export function LocalSeoPage({
) : (
<Search className="size-3.5" />
)}
Look up
{meteredActionLabel(
"Look up",
{ kind: "paidRequests", count: 1 },
true,
)}
</button>
</form>
<LocalGscContext projectId={projectId} context={projectContext} />
Expand Down
9 changes: 8 additions & 1 deletion src/client/features/rank-tracking/ToolbarMenus.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { meteredActionLabel } from "@/client/components/MeteredActionLabel";
import { useState, type ReactNode } from "react";
import {
ChevronDown,
Expand Down Expand Up @@ -120,7 +121,13 @@ export function MoreMenu({
className={`size-3.5 ${metricsRefreshing ? "animate-spin" : ""}`}
/>
}
label={metricsRefreshing ? "Refreshing..." : "Update keyword stats"}
label={
metricsRefreshing
? "Refreshing..."
: meteredActionLabel("Update keyword stats", {
kind: "credits",
})
}
description="Volume, difficulty & CPC — not rankings"
onClick={onRefreshMetrics}
disabled={metricsRefreshing || !hasData}
Expand Down
Loading