From 703861f5981781dd93b544a987ab01221aaccd9b Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Sat, 24 Oct 2026 05:23:53 -0400 Subject: [PATCH] feat(ai): offer new installs one AI title suggestion after their first analysis Dismissing onboarding marks an install as fresh (chrome.storage.local "first_run_ai_try" = "pending"); installs that were past onboarding before this shipped never get the prompt. The first completed analysis spends the mark: on the free tier the score page shows one dismissible card, "Try one AI suggestion for this page's title". One click sends the same hosted /ai/generate title request as the Meta Tags breakdown, counted against the 25 free calls as today, and the result shows inline in the usual box with its copy button. Pro, or an allowance already used up, spends the mark without a prompt. The paywall and quota behavior are unchanged. Part of #80. Co-Authored-By: Claude Opus 5.5 --- app/e2e/freemium.e2e.ts | 5 + app/src/components/FirstRunAiTry.test.tsx | 202 +++++++++++++++++++++ app/src/components/FirstRunAiTry.tsx | 138 ++++++++++++++ app/src/components/Onboarding.test.tsx | 37 ++++ app/src/components/Onboarding.tsx | 4 + app/src/lib/first-run-ai-try.test.ts | 111 +++++++++++ app/src/lib/first-run-ai-try.ts | 65 +++++++ app/src/sidepanel/pages/ScorePage.test.tsx | 20 ++ app/src/sidepanel/pages/ScorePage.tsx | 4 + docs/free-vs-pro-gating.md | 1 + 10 files changed, 587 insertions(+) create mode 100644 app/src/components/FirstRunAiTry.test.tsx create mode 100644 app/src/components/FirstRunAiTry.tsx create mode 100644 app/src/components/Onboarding.test.tsx create mode 100644 app/src/lib/first-run-ai-try.test.ts create mode 100644 app/src/lib/first-run-ai-try.ts diff --git a/app/e2e/freemium.e2e.ts b/app/e2e/freemium.e2e.ts index bc1f7cd..4e50f9a 100644 --- a/app/e2e/freemium.e2e.ts +++ b/app/e2e/freemium.e2e.ts @@ -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) => { @@ -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(); diff --git a/app/src/components/FirstRunAiTry.test.tsx b/app/src/components/FirstRunAiTry.test.tsx new file mode 100644 index 0000000..54f0fee --- /dev/null +++ b/app/src/components/FirstRunAiTry.test.tsx @@ -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(); + + 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(); + await waitFor(() => expect(prompt()).not.toBeInTheDocument()); + unmount(); + useFirstRunAiTry.setState({ offeredFor: null, suggestion: null }); + render(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + await user.click(await screen.findByRole("button", { name: /suggest a title/i })); + await screen.findByDisplayValue("Best SEO Tools for Small Sites | Example"); + + unmount(); + render(); + + 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(); + + 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(); + + await user.click(await screen.findByRole("button", { name: /dismiss/i })); + + expect(prompt()).not.toBeInTheDocument(); + expect(generateRecommendationMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/FirstRunAiTry.tsx b/app/src/components/FirstRunAiTry.tsx new file mode 100644 index 0000000..d04edee --- /dev/null +++ b/app/src/components/FirstRunAiTry.tsx @@ -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(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 ( +
+
+
+ +
+
+

+ Try one AI suggestion for this page's title +

+

+ Powered by Claude (Anthropic). Uses one of your {freeAiLimit ?? FREE_AI_LIMIT} free AI + recommendations this month. +

+
+ +
+ + {suggestion === null ? ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ) : ( + + )} +
+ ); +} diff --git a/app/src/components/Onboarding.test.tsx b/app/src/components/Onboarding.test.tsx new file mode 100644 index 0000000..91e6364 --- /dev/null +++ b/app/src/components/Onboarding.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/app/src/components/Onboarding.tsx b/app/src/components/Onboarding.tsx index 829b54d..97b89f1 100644 --- a/app/src/components/Onboarding.tsx +++ b/app/src/components/Onboarding.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { X, Search, Sparkles, BarChart3 } from "lucide-react"; import { getStorageItem, setStorageItem } from "@/lib/storage"; +import { markFirstRunAiTryPending } from "@/lib/first-run-ai-try"; import { Button } from "./ui/Button"; import { OptiaMark } from "./ui/Logo"; @@ -16,6 +17,9 @@ export function Onboarding() { const handleDismiss = async () => { setVisible(false); await setStorageItem("onboarding_dismissed", true); + // Getting past onboarding is what makes this a fresh install: queue the + // one-time AI try for after its first analysis (#80). + await markFirstRunAiTryPending(); }; if (!visible) return null; diff --git a/app/src/lib/first-run-ai-try.test.ts b/app/src/lib/first-run-ai-try.test.ts new file mode 100644 index 0000000..27b9843 --- /dev/null +++ b/app/src/lib/first-run-ai-try.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + FIRST_RUN_AI_TRY_KEY, + claimFirstRunAiTry, + markFirstRunAiTryPending, + useFirstRunAiTry, +} from "@/lib/first-run-ai-try"; +import { useEntitlementStore } from "@/lib/entitlement-store"; +import { useStore } from "@/lib/store"; +import { getStorageItem } from "@/lib/storage"; + +const ANALYSIS_AT = 1_793_000_000_000; + +function setFree(freeAiRemaining: number | null = null) { + useEntitlementStore.setState({ isPro: false, freeAiRemaining, freeAiLimit: 25 }); +} + +function setPro() { + useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 1000, quotaLimit: 1000 }); +} + +beforeEach(() => { + useStore.setState({ apiKey: "", useOwnKey: true, apiKeyInvalid: false }); + useFirstRunAiTry.setState({ offeredFor: null, suggestion: null }); + setFree(); +}); + +describe("claimFirstRunAiTry", () => { + it("never succeeds for an install that was past onboarding before this shipped", async () => { + expect(await claimFirstRunAiTry()).toBe(false); + expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBeNull(); + }); + + it("succeeds once after onboarding marks a fresh install, then never again", async () => { + await markFirstRunAiTryPending(); + expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("pending"); + + expect(await claimFirstRunAiTry()).toBe(true); + expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("done"); + expect(await claimFirstRunAiTry()).toBe(false); + }); + + it("does not re-arm a spent try when onboarding is marked again", async () => { + await markFirstRunAiTryPending(); + await claimFirstRunAiTry(); + + await markFirstRunAiTryPending(); + + expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("done"); + expect(await claimFirstRunAiTry()).toBe(false); + }); + + it("gives overlapping calls one shared claim (an effect that runs twice)", async () => { + await markFirstRunAiTryPending(); + + const results = await Promise.all([claimFirstRunAiTry(), claimFirstRunAiTry()]); + + expect(results).toEqual([true, true]); + expect(chrome.storage.local.set).toHaveBeenCalledTimes(2); // the mark, then one "done" + expect(await claimFirstRunAiTry()).toBe(false); + }); +}); + +describe("useFirstRunAiTry.offerAfterAnalysis", () => { + it("offers the try on a fresh free install's first analysis", async () => { + await markFirstRunAiTryPending(); + + await useFirstRunAiTry.getState().offerAfterAnalysis(ANALYSIS_AT); + + expect(useFirstRunAiTry.getState().offeredFor).toBe(ANALYSIS_AT); + }); + + it("offers nothing after later analyses", async () => { + await markFirstRunAiTryPending(); + await useFirstRunAiTry.getState().offerAfterAnalysis(ANALYSIS_AT); + useFirstRunAiTry.getState().dismiss(); + + await useFirstRunAiTry.getState().offerAfterAnalysis(ANALYSIS_AT + 60_000); + + expect(useFirstRunAiTry.getState().offeredFor).toBeNull(); + }); + + it("never offers on Pro, and Pro uses the try up", async () => { + setPro(); + await markFirstRunAiTryPending(); + + await useFirstRunAiTry.getState().offerAfterAnalysis(ANALYSIS_AT); + + expect(useFirstRunAiTry.getState().offeredFor).toBeNull(); + expect(await getStorageItem(FIRST_RUN_AI_TRY_KEY)).toBe("done"); + }); + + it("does not offer a try the free allowance can no longer pay for", async () => { + setFree(0); + await markFirstRunAiTryPending(); + + await useFirstRunAiTry.getState().offerAfterAnalysis(ANALYSIS_AT); + + expect(useFirstRunAiTry.getState().offeredFor).toBeNull(); + }); + + it("dismiss hides the prompt and drops its suggestion", async () => { + await markFirstRunAiTryPending(); + await useFirstRunAiTry.getState().offerAfterAnalysis(ANALYSIS_AT); + useFirstRunAiTry.getState().setSuggestion("Best SEO Tools for 2026"); + + useFirstRunAiTry.getState().dismiss(); + + expect(useFirstRunAiTry.getState()).toMatchObject({ offeredFor: null, suggestion: null }); + }); +}); diff --git a/app/src/lib/first-run-ai-try.ts b/app/src/lib/first-run-ai-try.ts new file mode 100644 index 0000000..f5a2d37 --- /dev/null +++ b/app/src/lib/first-run-ai-try.ts @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import { aiStatusNow } from "@/lib/entitlement-store"; +import { getStorageItem, setStorageItem } from "@/lib/storage"; + +// First-run AI try (#80): a fresh install gets one prompt, after its first +// completed analysis, to run a single hosted AI title suggestion. Dismissing +// onboarding marks an install as fresh, so installs that were past onboarding +// before this shipped never see it. The first completed analysis uses the mark +// up whether or not the prompt shows: it appears at most once per install, and +// only on the free tier. + +export const FIRST_RUN_AI_TRY_KEY = "first_run_ai_try"; + +type FirstRunAiTryState = "pending" | "done"; + +/** Called when onboarding is dismissed: this install gets the try after its first analysis. */ +export async function markFirstRunAiTryPending(): Promise { + if ((await getStorageItem(FIRST_RUN_AI_TRY_KEY)) === null) { + await setStorageItem(FIRST_RUN_AI_TRY_KEY, "pending"); + } +} + +let claimInFlight: Promise | null = null; + +/** True exactly once for a marked install, then never again. */ +export function claimFirstRunAiTry(): Promise { + // Shared while in flight, so an effect that runs twice (StrictMode) claims once. + claimInFlight ??= (async () => { + const state = await getStorageItem(FIRST_RUN_AI_TRY_KEY); + if (state !== "pending") return false; + await setStorageItem(FIRST_RUN_AI_TRY_KEY, "done"); + return true; + })().finally(() => { + claimInFlight = null; + }); + return claimInFlight; +} + +interface FirstRunAiTryStore { + /** Timestamp of the analysis the prompt belongs to; null while no prompt shows. */ + offeredFor: number | null; + /** The suggestion the try produced, kept while moving between the score and its sections. */ + suggestion: string | null; + /** Runs after an analysis completes; offers the try if this is the install's first. */ + offerAfterAnalysis: (analysisTimestamp: number) => Promise; + setSuggestion: (suggestion: string) => void; + dismiss: () => void; +} + +export const useFirstRunAiTry = create((set) => ({ + offeredFor: null, + suggestion: null, + + offerAfterAnalysis: async (analysisTimestamp) => { + if (!(await claimFirstRunAiTry())) return; + // Pro, or a free allowance already used up, spends the claim without a prompt. + if (aiStatusNow().mode === "free") { + set({ offeredFor: analysisTimestamp, suggestion: null }); + } + }, + + setSuggestion: (suggestion) => set({ suggestion }), + + dismiss: () => set({ offeredFor: null, suggestion: null }), +})); diff --git a/app/src/sidepanel/pages/ScorePage.test.tsx b/app/src/sidepanel/pages/ScorePage.test.tsx index 53f0b99..454b40a 100644 --- a/app/src/sidepanel/pages/ScorePage.test.tsx +++ b/app/src/sidepanel/pages/ScorePage.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import { useStore } from "@/lib/store"; import { useEntitlementStore } from "@/lib/entitlement-store"; +import { useFirstRunAiTry } from "@/lib/first-run-ai-try"; import { ScorePage } from "./ScorePage"; import type { SEOAnalysis, PageSEOData } from "@/types/seo"; @@ -58,6 +59,7 @@ const mockAnalysis: SEOAnalysis = { beforeEach(() => { useEntitlementStore.setState({ entitlementLoaded: false, isPro: false, tier: "free" }); + useFirstRunAiTry.setState({ offeredFor: null, suggestion: null }); useStore.setState({ view: "score", analysis: null, @@ -138,6 +140,24 @@ describe("ScorePage", () => { expect(screen.queryByRole("button", { name: /upgrade/i })).not.toBeInTheDocument(); }); + it("shows the first-run AI try above the breakdown when it was offered for this analysis", () => { + useFirstRunAiTry.setState({ offeredFor: mockAnalysis.timestamp, suggestion: null }); + useStore.setState({ analysis: mockAnalysis }); + render(); + + const tryHeading = screen.getByRole("heading", { name: /try one ai suggestion/i }); + expect( + tryHeading.compareDocumentPosition(screen.getByText("Meta Tags")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("shows no AI try for an analysis it was not offered for", () => { + useStore.setState({ analysis: mockAnalysis }); + render(); + expect(screen.queryByRole("heading", { name: /try one ai suggestion/i })).not.toBeInTheDocument(); + }); + it("shows warning banner when fetchWarnings present", () => { const analysisWithWarnings: SEOAnalysis = { ...mockAnalysis, diff --git a/app/src/sidepanel/pages/ScorePage.tsx b/app/src/sidepanel/pages/ScorePage.tsx index fbd6e9b..0c0dd60 100644 --- a/app/src/sidepanel/pages/ScorePage.tsx +++ b/app/src/sidepanel/pages/ScorePage.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, AlertTriangle, Sparkles } from "lucide-react"; import confetti from "canvas-confetti"; import { ScoreGauge } from "@/components/ui/ScoreGauge"; import { SummaryCard } from "@/components/SummaryCard"; +import { FirstRunAiTry } from "@/components/FirstRunAiTry"; import { Footer } from "@/components/Footer"; import { PanelHeader } from "@/components/PanelHeader"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; @@ -106,6 +107,9 @@ export function ScorePage() { + {/* One-time AI try after a fresh install's first analysis */} + + {/* Category breakdown */}
{analysis.categories.map((cat) => ( diff --git a/docs/free-vs-pro-gating.md b/docs/free-vs-pro-gating.md index 133712c..3e9ed64 100644 --- a/docs/free-vs-pro-gating.md +++ b/docs/free-vs-pro-gating.md @@ -11,6 +11,7 @@ from that store — never an ad-hoc `apiKey` check or raw license data. | On-page SEO scoring | ✅ Full | ✅ Full | *ungated — never gate* | | Score breakdown (per-check detail) | ✅ Full | ✅ Full | *ungated — never gate* | | AI recommendations (title, meta, H1, URL, intro, H2, alt text) | ✅ Capped monthly, via hosted proxy | ✅ Higher cap via proxy, or unlimited with own key | `useCanUseAI()` / `useAiStatus()` | +| First-run AI try (one title suggestion offered after a fresh install's first analysis, #80) | ✅ Once per install; counts against the monthly allowance | ❌ Never shown | `aiStatusNow().mode === "free"` at the first analysis; hidden whenever `isPro` | | Advanced analysis (page type, secondary keywords) | ❌ | ✅ | `canUseAdvancedOptions` | | Multi-language AI output | ❌ English only | ✅ Any supported language | `canUseMultiLanguage` | | Schema markup generation | ❌ | ✅ | `canUseSchema` |