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
5 changes: 5 additions & 0 deletions app/e2e/freemium.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ test.describe("Free tier", () => {
test("fresh install shows onboarding once, then hydrates as Free — no console errors", async ({
extensionContext,
extensionId,
extensionServiceWorker,
}) => {
const unexpected: string[] = [];
await extensionContext.route(BACKEND_GLOB, (route) => {
Expand All @@ -78,6 +79,10 @@ test.describe("Free tier", () => {
await expect(getStarted).toBeVisible();
await getStarted.click();
await expect(getStarted).toBeHidden();
// Getting past onboarding queues the one-time AI try for the first analysis (#80).
await expect
.poll(async () => readStorage(extensionServiceWorker, "first_run_ai_try"))
.toBe("pending");

await expect(page.getByRole("button", { name: /upgrade/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /set up your seo analysis/i })).toBeVisible();
Expand Down
202 changes: 202 additions & 0 deletions app/src/components/FirstRunAiTry.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FirstRunAiTry } from "./FirstRunAiTry";
import { generateRecommendation } from "@/lib/ai";
import { AiProxyError } from "@/lib/ai-proxy";
import { useEntitlementStore } from "@/lib/entitlement-store";
import { FIRST_RUN_AI_TRY_KEY, useFirstRunAiTry } from "@/lib/first-run-ai-try";
import { getStorageItem } from "@/lib/storage";
import { useStore } from "@/lib/store";
import type { PageSEOData, SEOAnalysis } from "@/types/seo";

vi.mock("@/lib/ai", () => ({ generateRecommendation: vi.fn() }));

const generateRecommendationMock = vi.mocked(generateRecommendation);

const pageData: PageSEOData = {
url: "https://example.com/tools",
title: "Tools | Example",
metaDescription: "",
metaKeywords: "",
canonical: "",
h1: ["Tools"],
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
images: [],
ogTags: {},
twitterTags: {},
wordCount: 300,
internalLinks: 4,
externalLinks: 1,
lang: "en",
paragraphs: [],
resources: { js: [], css: [] },
schemaMarkup: { types: [], count: 0 },
ogImage: "",
imageFileSizes: [],
fetchWarnings: [],
};

function analysisAt(timestamp: number): SEOAnalysis {
return {
overallScore: 62,
scoreLabel: "Fair",
scoreDescription: "A solid start!",
totalPassed: 10,
totalFailed: 8,
categories: [],
pageData,
keyword: "seo tools",
timestamp,
};
}

const FIRST = analysisAt(1_793_000_000_000);
const SECOND = analysisAt(1_793_000_060_000);

const prompt = () => screen.queryByRole("heading", { name: /try one ai suggestion for this page's title/i });

async function freshInstall() {
await chrome.storage.local.set({ [FIRST_RUN_AI_TRY_KEY]: "pending" });
}

beforeEach(() => {
useEntitlementStore.setState({
entitlementLoaded: true,
isPro: false,
aiQuotaRemaining: 0,
quotaLimit: 0,
freeAiRemaining: null,
freeAiLimit: null,
});
useStore.setState({
apiKey: "",
useOwnKey: true,
apiKeyInvalid: false,
toast: { visible: false, message: "" },
settings: {
keyword: "seo tools",
secondaryKeywords: "",
pageType: "homepage",
language: "en",
advancedMode: false,
targetUrl: "",
},
});
useFirstRunAiTry.setState({ offeredFor: null, suggestion: null });
generateRecommendationMock.mockResolvedValue("Best SEO Tools for Small Sites | Example");
});

describe("FirstRunAiTry", () => {
it("appears after a fresh install's first analysis, and only that once", async () => {
await freshInstall();
const { rerender, unmount } = render(<FirstRunAiTry analysis={FIRST} />);

expect(await screen.findByRole("heading", { name: /try one ai suggestion/i })).toBeInTheDocument();
expect(screen.getByText(/uses one of your 25 free ai recommendations this month/i)).toBeInTheDocument();
expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("done");

// The next analysis gets no prompt, in this panel or a reopened one.
rerender(<FirstRunAiTry analysis={SECOND} />);
await waitFor(() => expect(prompt()).not.toBeInTheDocument());
unmount();
useFirstRunAiTry.setState({ offeredFor: null, suggestion: null });
render(<FirstRunAiTry analysis={SECOND} />);
await waitFor(() => expect(chrome.storage.local.get).toHaveBeenCalled());
expect(prompt()).not.toBeInTheDocument();
});

it("never appears on an install that was past onboarding before this shipped", async () => {
render(<FirstRunAiTry analysis={FIRST} />);

await waitFor(() => expect(chrome.storage.local.get).toHaveBeenCalled());
expect(prompt()).not.toBeInTheDocument();
});

it("never appears on Pro", async () => {
await freshInstall();
useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 1000, quotaLimit: 1000 });
render(<FirstRunAiTry analysis={FIRST} />);

await waitFor(async () => expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("done"));
expect(prompt()).not.toBeInTheDocument();
});

it("waits for the entitlement before deciding", async () => {
await freshInstall();
useEntitlementStore.setState({ entitlementLoaded: false });
render(<FirstRunAiTry analysis={FIRST} />);

expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("pending");
expect(prompt()).not.toBeInTheDocument();

useEntitlementStore.setState({ entitlementLoaded: true });
expect(await screen.findByRole("heading", { name: /try one ai suggestion/i })).toBeInTheDocument();
});

it("asks for the title suggestion the normal path does and shows it with a copy button", async () => {
await freshInstall();
const user = userEvent.setup();
render(<FirstRunAiTry analysis={FIRST} />);

await user.click(await screen.findByRole("button", { name: /suggest a title/i }));

expect(generateRecommendationMock).toHaveBeenCalledTimes(1);
expect(generateRecommendationMock).toHaveBeenCalledWith(
"title-keyword",
"seo tools",
"Tools | Example",
{ languageCode: "en" },
);
expect(await screen.findByDisplayValue("Best SEO Tools for Small Sites | Example")).toBeInTheDocument();
expect(screen.getByText(/ai suggested title/i)).toBeInTheDocument();
expect(screen.getByTitle(/copy to clipboard/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /suggest a title/i })).not.toBeInTheDocument();
});

it("keeps the suggestion when the score page is left and reopened", async () => {
await freshInstall();
const user = userEvent.setup();
const { unmount } = render(<FirstRunAiTry analysis={FIRST} />);
await user.click(await screen.findByRole("button", { name: /suggest a title/i }));
await screen.findByDisplayValue("Best SEO Tools for Small Sites | Example");

unmount();
render(<FirstRunAiTry analysis={FIRST} />);

expect(await screen.findByDisplayValue("Best SEO Tools for Small Sites | Example")).toBeInTheDocument();
expect(generateRecommendationMock).toHaveBeenCalledTimes(1);
});

it("shows the quota message inline and as a toast when the allowance is used up", async () => {
await freshInstall();
generateRecommendationMock.mockRejectedValue(
new AiProxyError("quota_exceeded", "You've used your free AI recommendations for this month."),
);
const user = userEvent.setup();
render(<FirstRunAiTry analysis={FIRST} />);

await user.click(await screen.findByRole("button", { name: /suggest a title/i }));

expect(await screen.findByRole("alert")).toHaveTextContent(/used your free ai recommendations/i);
expect(useStore.getState().toast).toEqual({
visible: true,
message: "You've used your free AI recommendations for this month.",
});
});

it("goes away when dismissed", async () => {
await freshInstall();
const user = userEvent.setup();
render(<FirstRunAiTry analysis={FIRST} />);

await user.click(await screen.findByRole("button", { name: /dismiss/i }));

expect(prompt()).not.toBeInTheDocument();
expect(generateRecommendationMock).not.toHaveBeenCalled();
});
});
138 changes: 138 additions & 0 deletions app/src/components/FirstRunAiTry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { EditableRecommendation } from "@/components/EditableRecommendation";
import { generateRecommendation } from "@/lib/ai";
import { aiErrorMessage } from "@/lib/ai-error";
import { useCanUseAI, useEntitlementStore } from "@/lib/entitlement-store";
import { useFirstRunAiTry } from "@/lib/first-run-ai-try";
import { FREE_AI_LIMIT } from "@/lib/plans";
import { useStore } from "@/lib/store";
import type { SEOAnalysis } from "@/types/seo";

interface FirstRunAiTryProps {
analysis: SEOAnalysis;
}

/**
* The one-time prompt after a fresh install's first analysis (#80). One click
* sends the same hosted title request as the "AI Suggested Title" box in the
* Meta Tags breakdown, counted against the free monthly allowance like any
* other. Free tier only; dismissing it hides it for good.
*/
export function FirstRunAiTry({ analysis }: FirstRunAiTryProps) {
const settings = useStore((s) => s.settings);
const showToast = useStore((s) => s.showToast);
const entitlementLoaded = useEntitlementStore((s) => s.entitlementLoaded);
const isPro = useEntitlementStore((s) => s.isPro);
const freeAiLimit = useEntitlementStore((s) => s.freeAiLimit);
const aiDisabled = !useCanUseAI();
const offeredFor = useFirstRunAiTry((s) => s.offeredFor);
const suggestion = useFirstRunAiTry((s) => s.suggestion);
const offerAfterAnalysis = useFirstRunAiTry((s) => s.offerAfterAnalysis);
const setSuggestion = useFirstRunAiTry((s) => s.setSuggestion);
const dismiss = useFirstRunAiTry((s) => s.dismiss);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Wait for the entitlement so a Pro install is never mistaken for free.
useEffect(() => {
if (entitlementLoaded) void offerAfterAnalysis(analysis.timestamp);
}, [entitlementLoaded, analysis.timestamp, offerAfterAnalysis]);

if (offeredFor !== analysis.timestamp || isPro) return null;

// Built the same way as the options the Meta Tags breakdown passes.
const advancedOptions = {
...(settings.advancedMode
? {
pageType: settings.pageType,
secondaryKeywords: settings.secondaryKeywords,
}
: {}),
languageCode: settings.language,
};

const suggestTitle = async () => {
const text = await generateRecommendation(
"title-keyword",
analysis.keyword,
analysis.pageData.title,
advancedOptions,
);
setSuggestion(text);
return text;
};

const handleTry = async () => {
setLoading(true);
setError(null);
try {
await suggestTitle();
} catch (err) {
const message = aiErrorMessage(err, "Could not generate a suggestion");
showToast(message);
setError(message);
} finally {
setLoading(false);
}
};

return (
<section
aria-label="Try an AI suggestion"
className="mb-3 animate-fade-in rounded-card-lg border border-border bg-surface p-4 shadow-card"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-accent/15">
<Sparkles className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-body-semibold text-ink">
Try one AI suggestion for this page&apos;s title
</h3>
<p className="mt-0.5 text-body-12 text-muted">
Powered by Claude (Anthropic). Uses one of your {freeAiLimit ?? FREE_AI_LIMIT} free AI
recommendations this month.
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="-mr-1 -mt-1 rounded-full p-1.5 text-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
<X className="h-4 w-4" />
</button>
</div>

{suggestion === null ? (
<div className="mt-3 flex flex-col gap-2 pl-12">
<Button
size="small"
className="self-start"
onClick={handleTry}
loading={loading}
disabled={aiDisabled}
>
Suggest a title
</Button>
{error && (
<p role="alert" className="text-body-12 text-poor">
{error}
</p>
)}
</div>
) : (
<EditableRecommendation
className="mt-3"
label="AI Suggested Title"
initialValue={suggestion}
onRegenerate={suggestTitle}
onToast={showToast}
aiDisabled={aiDisabled}
/>
)}
</section>
);
}
37 changes: 37 additions & 0 deletions app/src/components/Onboarding.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { Onboarding } from "./Onboarding";
import { FIRST_RUN_AI_TRY_KEY } from "@/lib/first-run-ai-try";
import { getStorageItem } from "@/lib/storage";

describe("Onboarding", () => {
it("shows on a fresh install and, once dismissed, queues the first-run AI try", async () => {
const user = userEvent.setup();
render(<Onboarding />);

await user.click(await screen.findByRole("button", { name: /get started/i }));

expect(screen.queryByRole("button", { name: /get started/i })).not.toBeInTheDocument();
await waitFor(async () => expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("pending"));
expect(await getStorageItem("onboarding_dismissed")).toBe(true);
});

it("queues the try when closed with the X as well", async () => {
const user = userEvent.setup();
render(<Onboarding />);

await user.click(await screen.findByRole("button", { name: /close/i }));

await waitFor(async () => expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("pending"));
});

it("stays hidden, and queues nothing, once onboarding was dismissed before", async () => {
await chrome.storage.local.set({ onboarding_dismissed: true });
render(<Onboarding />);

await waitFor(() => expect(chrome.storage.local.get).toHaveBeenCalledWith("onboarding_dismissed"));
expect(screen.queryByRole("button", { name: /get started/i })).not.toBeInTheDocument();
expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBeNull();
});
});
Loading
Loading