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` |