← back to Open Seo

src/client/features/onboarding/OnboardingChatParts.tsx

321 lines

import {
  useLayoutEffect,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent,
} from "react";
import { ArrowUp, Check, Globe, Loader2, Sparkles } from "lucide-react";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";

const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";

export function SuggestedQuestions({
  questions,
  primaryQuestions = [],
  onSelect,
}: {
  questions: string[];
  primaryQuestions?: string[];
  onSelect: (question: string) => void;
}) {
  return (
    <div className="ml-10 flex flex-wrap gap-2">
      {questions.map((question) =>
        primaryQuestions.includes(question) ? (
          <button
            key={question}
            type="button"
            className="inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/15"
            onClick={() => onSelect(question)}
          >
            <Sparkles className="size-3.5" />
            {question}
          </button>
        ) : (
          <button
            key={question}
            type="button"
            className="rounded-full border border-base-300 bg-base-100 px-3 py-1.5 text-xs font-medium text-base-content/70 transition-colors hover:border-primary/50 hover:text-base-content"
            onClick={() => onSelect(question)}
          >
            {question}
          </button>
        ),
      )}
    </div>
  );
}

export function WelcomeMessage({
  domain,
  checkoutError,
  isStartingCheckout,
  onUpgrade,
}: {
  domain: string;
  checkoutError: string | null;
  isStartingCheckout: boolean;
  onUpgrade: () => void;
}) {
  return (
    <div className="flex gap-3">
      <div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
        <Sparkles className="size-4" />
      </div>
      <div className="min-w-0 flex-1 space-y-3 pt-0.5 text-sm">
        <div className="space-y-3 text-base-content/80">
          <p>Hey, I’m Sam — welcome to OpenSEO.</p>
          <p>
            To get full access to OpenSEO, you need to upgrade to the paid plan.
            But, I’m here if you have any questions.
          </p>
          <p>
            You can also{" "}
            <a
              href={DISCORD_URL}
              target="_blank"
              rel="noreferrer"
              className="link link-primary"
            >
              join the Discord
            </a>{" "}
            or email{" "}
            <a href="mailto:ben@openseo.so" className="link link-primary">
              ben@openseo.so
            </a>{" "}
            if you have any questions I can’t help you with.
          </p>
          <p>
            Want me to analyze{" "}
            <span className="font-medium text-base-content">{domain}</span> and
            draft a strategy, or do you have questions first? Pick one below to
            get started.
          </p>
        </div>

        <div className="rounded-box border border-base-300 bg-base-200/50 p-3 text-xs lg:hidden">
          <p className="font-medium">Want Sam to keep going?</p>
          <p className="mt-0.5 text-base-content/70">
            Upgrade to run keyword research, rank tracking, and site audits on{" "}
            {domain}.
          </p>
          <button
            type="button"
            className="btn btn-primary btn-xs mt-2"
            disabled={isStartingCheckout}
            onClick={onUpgrade}
          >
            {isStartingCheckout ? "Redirecting..." : "Upgrade"}
          </button>
          {checkoutError ? (
            <p className="mt-2 text-error">{checkoutError}</p>
          ) : null}
        </div>
      </div>
    </div>
  );
}

// Left-rail upgrade CTA. Hidden below `lg` (the inline callout + remaining
// hint cover narrow viewports).
export function UpgradeSidebar({
  domain,
  questionsUsed,
  isStartingCheckout,
  onUpgrade,
}: {
  domain: string;
  questionsUsed: number;
  isStartingCheckout: boolean;
  onUpgrade: () => void;
}) {
  const features = [
    "Keyword research, backlinks, rank tracking & site audits",
    "Google Search Console — read-only, no credits, no Google Cloud setup",
    "Connect Claude, Cursor, Codex & other MCP clients",
    "Top-up credits roll over and never expire",
  ];
  const used = Math.min(questionsUsed, FREE_ONBOARDING_QUESTION_LIMIT);
  const progress = (used / FREE_ONBOARDING_QUESTION_LIMIT) * 100;

  return (
    <aside className="hidden w-96 flex-shrink-0 flex-col border-r border-base-300 bg-base-200/20 lg:flex">
      <div className="flex items-center gap-2.5 border-b border-base-300 px-6 py-4 text-xs text-base-content/55">
        <span className="inline-flex size-8 items-center justify-center rounded-full border border-base-300 bg-base-100 text-primary">
          <Globe className="size-4" />
        </span>
        <div className="min-w-0">
          <p className="font-medium text-base-content/80">Previewing OpenSEO</p>
          <p className="truncate" title={domain}>
            {domain}
          </p>
        </div>
      </div>

      <div className="flex flex-1 flex-col gap-5 px-6 py-6">
        <div>
          <div className="flex items-baseline gap-1.5">
            <span className="text-3xl font-semibold tracking-tight">$10</span>
            <span className="text-sm text-base-content/55">/month</span>
          </div>
          <p className="mt-1.5 text-xs leading-relaxed text-base-content/55">
            Includes $10 of usage credits every month, plus a 30-day money-back
            guarantee.
          </p>
        </div>

        <ul className="space-y-3 border-t border-base-300 pt-5">
          {features.map((label) => (
            <li
              key={label}
              className="flex gap-2.5 text-sm leading-snug text-base-content/75"
            >
              <Check className="mt-0.5 size-4 flex-shrink-0 text-primary" />
              <span>{label}</span>
            </li>
          ))}
        </ul>

        <div className="mt-auto space-y-3 pt-2">
          <button
            type="button"
            className="btn btn-primary w-full"
            disabled={isStartingCheckout}
            onClick={onUpgrade}
          >
            {isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
          </button>
          <p className="text-center text-xs leading-relaxed text-base-content/55">
            Want advice from other OpenSEO users?{" "}
            <a
              href={DISCORD_URL}
              target="_blank"
              rel="noreferrer"
              className="link link-primary"
            >
              Join the Discord
            </a>
            .
          </p>
        </div>
      </div>

      <div className="space-y-1.5 border-t border-base-300 px-6 py-4">
        <div className="h-1 w-full overflow-hidden rounded-full bg-base-300">
          <div
            className="h-full rounded-full bg-primary transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
        <p className="text-xs text-base-content/55">
          {used} of {FREE_ONBOARDING_QUESTION_LIMIT} free questions used
        </p>
      </div>
    </aside>
  );
}

// Replaces the composer once a free user exhausts their question allowance.
export function ChatGate({
  isStartingCheckout,
  onUpgrade,
}: {
  isStartingCheckout: boolean;
  onUpgrade: () => void;
}) {
  return (
    <div className="flex-shrink-0 border-t border-base-300 px-5 py-4">
      <div className="mx-auto w-full max-w-2xl rounded-box border border-primary/30 bg-primary/5 p-4 text-center">
        <p className="text-sm font-medium">
          That’s all {FREE_ONBOARDING_QUESTION_LIMIT} free questions
        </p>
        <p className="mx-auto mt-1 max-w-md text-xs text-base-content/70">
          Upgrade to keep working with Sam and unlock the full OpenSEO app.
        </p>
        <button
          type="button"
          className="btn btn-primary btn-sm mt-3"
          disabled={isStartingCheckout}
          onClick={onUpgrade}
        >
          {isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
        </button>
        <p className="mt-2 text-xs text-base-content/45">
          30-day money-back guarantee
        </p>
      </div>
    </div>
  );
}

export function ChatComposer({
  busy,
  onSend,
  placeholder = "Ask Sam about your strategy or OpenSEO…",
}: {
  busy: boolean;
  onSend: (text: string) => void;
  placeholder?: string;
}) {
  const [value, setValue] = useState("");
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Auto-grow the textarea up to a few lines, then scroll. Resetting height to
  // `auto` first lets it shrink as well as grow.
  useLayoutEffect(() => {
    const ta = textareaRef.current;
    if (!ta) return;
    ta.style.height = "auto";
    ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
  }, [value]);

  function submit() {
    const text = value.trim();
    if (!text || busy) return;
    onSend(text);
    setValue("");
  }

  function handleSubmit(event: FormEvent) {
    event.preventDefault();
    submit();
  }

  function handleKey(event: KeyboardEvent<HTMLTextAreaElement>) {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      submit();
    }
  }

  return (
    <form
      onSubmit={handleSubmit}
      className="flex items-end gap-2 rounded-box border border-base-300 bg-base-100 px-3 py-2 focus-within:border-primary"
    >
      <textarea
        ref={textareaRef}
        value={value}
        onChange={(event) => setValue(event.target.value)}
        onKeyDown={handleKey}
        rows={1}
        placeholder={placeholder}
        className="max-h-40 flex-1 resize-none border-0 bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-base-content/50 focus:outline-none"
      />
      <button
        type="submit"
        aria-label="Send message"
        disabled={busy || !value.trim()}
        className="btn btn-primary btn-circle btn-sm"
      >
        {busy ? (
          <Loader2 className="size-4 animate-spin" />
        ) : (
          <ArrowUp className="size-4" />
        )}
      </button>
    </form>
  );
}