metatron-open-seo/src/client/features/onboarding/OnboardingChatConversation.tsx

279 lines
10 KiB
TypeScript

import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useCustomer } from "autumn-js/react";
import { useState } from "react";
import {
ChatMessage,
messageHasVisibleContent,
type ResolveToolLabel,
} from "@/client/components/chat/ChatMessage";
import { useStickToBottom } from "@/client/components/chat/useStickToBottom";
import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
import {
AUTUMN_CHECKOUT_SESSION_PARAMS,
AUTUMN_PAID_PLAN_ID,
} from "@/shared/billing";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import {
ChatComposer,
ChatGate,
SuggestedQuestions,
UpgradeSidebar,
WelcomeMessage,
} from "./OnboardingChatParts";
// Friendly labels for each tool Sam can run, so the chat shows what it's doing
// rather than going silent while it gathers site data. `running` shows while the
// call is in flight; `done` stays as a persistent badge once it finishes.
const TOOL_LABELS: Record<string, { running: string; done: string }> = {
"tool-read_website": { running: "Reading site", done: "Read site" },
"tool-get_seo_metrics": {
running: "Getting SEO metrics",
done: "SEO metrics",
},
"tool-research_keywords": {
running: "Researching keywords",
done: "Keyword research",
},
"tool-get_domain_overview": {
running: "Analyzing domain",
done: "Domain overview",
},
"tool-get_serp_results": {
running: "Checking search results",
done: "Search results",
},
"tool-find_serp_competitors": {
running: "Finding competitors",
done: "Competitors",
},
"tool-get_competitor_keywords": {
running: "Analyzing competitor",
done: "Competitor keywords",
},
"tool-get_backlinks_overview": {
running: "Checking backlinks",
done: "Backlinks overview",
},
};
// Onboarding curates a label per tool and hides any tool it hasn't named, so
// the pre-paywall preview only shows the handful it means to surface.
const resolveToolLabel: ResolveToolLabel = (partType) =>
TOOL_LABELS[partType] ?? null;
const SUGGESTED_QUESTIONS = [
"How will OpenSEO help me get more traffic?",
"Compare OpenSEO and Claude",
"What do I get after I upgrade?",
"How does the Google Search Console integration work?",
"Right fit for consultants and agencies?",
];
// Highlighted (primary) chips shown first, before the general questions.
// STRATEGY_SUGGESTION drops out once the user has asked for their strategy.
const STRATEGY_SUGGESTION = "What do you recommend for my site?";
const COMPETITOR_SUGGESTION = "Compare against my competitors";
const PRIMARY_SUGGESTIONS = [STRATEGY_SUGGESTION, COMPETITOR_SUGGESTION];
export function OnboardingChatConversation({
projectId,
domain,
}: {
projectId: string;
domain: string;
}) {
// The conversation lives in a Durable Object (Agents SDK), keyed by projectId,
// so history persists across reloads. The WebSocket connection is authorized
// in the Worker (src/server.ts) before it reaches the DO; billing gates come
// back as normal assistant messages rather than HTTP errors.
const agent = useAgent({ agent: "onboarding-chat", name: projectId });
// Same constraint as SAM: dense tool-input deltas fan out one store update
// per chunk and trip React #185 unthrottled (cloudflare/agents#1361).
const { messages, sendMessage, status } = useAgentChat({
agent,
experimental_throttle: 50,
});
// This chat is only ever the pre-upgrade free preview: once a user upgrades
// they are routed into the GSC onboarding step and never return here, so
// there's no "paid" state to model — the question cap always applies.
const customerQuery = useCustomer();
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [isStartingCheckout, setIsStartingCheckout] = useState(false);
const [usedSuggestions, setUsedSuggestions] = useState<string[]>([]);
// Set once the user asks for their strategy (welcome CTA or the strategy
// chip) so we don't keep offering the "What do you recommend" chip.
const [strategyRequested, setStrategyRequested] = useState(false);
const questionsUsed = messages.filter((m) => m.role === "user").length;
const remaining = Math.max(0, FREE_ONBOARDING_QUESTION_LIMIT - questionsUsed);
const isLocked = remaining <= 0;
// Nudge once they're within the last few questions, not from the start.
const showRemainingHint = remaining > 0 && remaining <= 3;
const isBusy = status === "submitted" || status === "streaming";
const { scrollRef, onScroll, pinToBottom } = useStickToBottom(
messages,
status,
);
const sendText = (text: string) => {
pinToBottom();
void sendMessage({ text });
};
async function startCheckout() {
setCheckoutError(null);
setIsStartingCheckout(true);
try {
captureClientEvent("billing:checkout_start");
// After payment, re-enter onboarding at the GSC step (not back into
// this chat) so the user finishes connecting Search Console + MCP.
await customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"),
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
});
} catch (checkoutErr) {
setCheckoutError(
getStandardErrorMessage(
checkoutErr,
"We couldn't start checkout. Please refresh and try again.",
),
);
setIsStartingCheckout(false);
}
}
const lastMessage = messages[messages.length - 1];
const suggestionPool = [
...(strategyRequested ? [] : [STRATEGY_SUGGESTION]),
COMPETITOR_SUGGESTION,
...SUGGESTED_QUESTIONS,
];
const remainingSuggestions = suggestionPool.filter(
(question) => !usedSuggestions.includes(question),
);
// Show the typing indicator from the moment the user sends until the
// assistant's reply shows something — covers the "submitted" wait (last
// message is still the user's own) and the gap before any text or tool badge
// renders. Once a tool badge is in flight, it carries the progress, so the
// dots would just double up.
const showTyping =
isBusy &&
(lastMessage?.role !== "assistant" ||
!messageHasVisibleContent(lastMessage));
// Show the chips up front (before the first message) and after each assistant
// reply, but not while a reply is mid-flight.
const showSuggestions =
remainingSuggestions.length > 0 &&
!isBusy &&
(messages.length === 0 || lastMessage?.role === "assistant");
return (
<div className="flex min-h-0 flex-1">
<UpgradeSidebar
domain={domain}
questionsUsed={questionsUsed}
isStartingCheckout={isStartingCheckout}
onUpgrade={() => void startCheckout()}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto px-5 py-6"
>
<div className="mx-auto max-w-2xl space-y-6">
<WelcomeMessage
domain={domain}
checkoutError={checkoutError}
isStartingCheckout={isStartingCheckout}
onUpgrade={() => void startCheckout()}
/>
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
resolveToolLabel={resolveToolLabel}
streaming={
isBusy &&
index === messages.length - 1 &&
message.role === "assistant"
}
/>
))}
{showTyping ? (
<div className="flex items-center gap-2 pt-1 text-base-content/40">
<span className="flex items-center gap-1.5">
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current" />
</span>
</div>
) : null}
{status === "error" ? (
<p className="text-sm text-error">
{/* Billing gates (free-question cap / out-of-credits) come
back as normal assistant messages now, so this only covers
genuine failures. */}
Something went wrong. Please refresh and try again.
</p>
) : null}
{showSuggestions ? (
<SuggestedQuestions
questions={remainingSuggestions}
primaryQuestions={PRIMARY_SUGGESTIONS}
onSelect={(question) => {
setUsedSuggestions((current) =>
current.includes(question)
? current
: [...current, question],
);
if (question === STRATEGY_SUGGESTION) {
setStrategyRequested(true);
}
sendText(question);
}}
/>
) : null}
</div>
</div>
{isLocked ? (
<ChatGate
isStartingCheckout={isStartingCheckout}
onUpgrade={() => void startCheckout()}
/>
) : (
<div className="flex-shrink-0 border-t border-base-300 px-5 py-3">
<div className="mx-auto w-full max-w-2xl space-y-2">
{showRemainingHint ? (
<p className="px-1 text-xs text-base-content/50">
{remaining} free question{remaining === 1 ? "" : "s"} left.{" "}
<button
type="button"
className="link link-primary"
disabled={isStartingCheckout}
onClick={() => void startCheckout()}
>
Upgrade for full access
</button>
</p>
) : null}
<ChatComposer busy={isBusy} onSend={sendText} />
</div>
</div>
)}
</div>
</div>
);
}