import { useState, useEffect, useRef } from "react";
import {
Copy, Check, Plus, Trash2, Sparkles, Loader2, ChevronRight, ChevronLeft,
AlertCircle, FileText, Globe, RefreshCw, ListPlus, ClipboardList
} from "lucide-react";
// ============== BRAND ==============
const MAGENTA = "#e4007d";
const CORAL = "#FF5757";
const TEAL = "#28BBB8";
const INK = "#2c2a32";
const SUBINK = "#6f6b78";
const LINE = "#e8e2d8";
const BG = "#faf6f0";
const CARD = "#ffffff";
const SOFT = "#f4eee4";
const SANS = "'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif";
const DISPLAY = "'Bricolage Grotesque', 'Hanken Grotesk', sans-serif";
// ============== API ==============
const MODEL = "claude-sonnet-4-6";
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// Pulls a human-readable message out of an API error body, whatever shape it is.
function readErrorBody(raw) {
if (!raw) return "";
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.error && parsed.error.message) return parsed.error.message;
if (parsed && parsed.message) return parsed.message;
} catch (e) {}
return String(raw).slice(0, 200);
}
async function callClaude(system, user, maxTokens = 4000) {
const body = JSON.stringify({
model: MODEL,
max_tokens: maxTokens,
system,
messages: [{ role: "user", content: user }],
});
const maxAttempts = 4;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let response;
try {
response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
} catch (netErr) {
if (attempt < maxAttempts) { await sleep(1500 * attempt); continue; }
throw new Error("Could not reach the AI. Check your connection and try again.");
}
// rate limited or temporarily overloaded: wait and retry
if (response.status === 429 || response.status === 529 || response.status === 503) {
if (attempt < maxAttempts) {
const ra = parseFloat(response.headers.get("retry-after"));
const wait = (!isNaN(ra) && ra > 0)
? Math.min(ra * 1000, 30000)
: Math.min(2000 * Math.pow(2, attempt - 1), 16000);
await sleep(wait);
continue;
}
throw new Error("The AI is rate-limited right now. Wait about a minute, then use Regenerate.");
}
if (!response.ok) {
let detail = "";
try { detail = readErrorBody(await response.text()); } catch (e) {}
throw new Error(
`The AI request failed (${response.status}).${detail ? " " + detail : ""}`
);
}
const data = await response.json();
const text = (data.content || [])
.filter(b => b.type === "text")
.map(b => b.text)
.join("\n");
if (!text.trim()) throw new Error("The AI returned an empty response. Try again.");
if (data.stop_reason === "max_tokens") {
// The plan was cut off mid-JSON. Hand it back anyway; the parser repairs
// what it can, and the caller will show a clear message if it cannot.
return text + "\n";
}
return text;
}
throw new Error("The AI request didn't go through. Try again in a moment.");
}
function parsePlanJSON(text) {
let t = (text || "").trim();
const wasTruncated = t.includes("");
t = t.replace("", "").trim();
t = t.replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
const open = t.indexOf("{");
if (open > 0) t = t.slice(open);
try { return JSON.parse(t); } catch (e) {}
// truncation repair: try progressively shorter prefixes ending at a closing brace
let cut = t.lastIndexOf("}");
while (cut > 0) {
try { return JSON.parse(t.slice(0, cut + 1)); } catch (e) {}
cut = t.lastIndexOf("}", cut - 1);
}
throw new Error(
wasTruncated
? "The plan came back too long and got cut off. Use Regenerate to try again."
: "Could not read the AI response. Try generating again."
);
}
async function generatePagePlan(practice, page) {
const system = `You are an SEO assistant for therapists and mental health practitioners in private practice. You write a complete on-page SEO plan for one webpage, optimized for a single target keyword the practitioner has already chosen.
Voice and rules:
- Write in plain, warm, professional language a therapist would actually use. No corporate jargon, no hype words.
- Do not use em dashes anywhere. Use commas, periods, semicolons, or parentheses.
- Write "zynnyme" in lowercase if it ever comes up.
- Use only the practice details provided. Never invent credentials, license numbers, years of experience, specialties, populations, or locations that were not given to you.
- Use the target keyword naturally. Never keyword-stuff.
- Location handling: the practitioner serves a primary location plus, sometimes, additional service areas. For a page whose keyword names a specific city or area, write the plan for that location. For general pages (homepage, about, contact), anchor on the primary location. Never name a location that is not in the list provided.
Return ONLY strict JSON. No preamble, no explanation, no markdown code fences. Use this exact schema:
{
"page_type": "your best guess at the page type, inferred from the page name (homepage, about, service page, contact, location, blog post, etc.)",
"meta_description": "150 to 160 characters. Includes the keyword naturally. Concrete and specific, gives a clear reason to click. Name the practitioner with their credential where it fits naturally, for example 'with Jordan Lee, LMFT'.",
"page_title": "Under 60 characters. The HTML title tag. Lead with the keyword where it reads naturally; the location can follow. If, and only if, it still fits within 60 characters, append the practitioner's name and credential after a vertical bar, for example 'Marriage & Couples Counseling Worcester, MA | Jordan Lee, LMFT'. If it does not fit, keep the keyword and location and leave the name out.",
"h1": "The single on-page H1 heading. Human and welcoming, includes the keyword or a close natural variant. Never stuffed.",
"subtopics": [
{ "h2": "a section heading", "h3": ["an optional sub-heading", "another optional sub-heading"] }
],
"slug": "The URL slug only, not the full URL. Lowercase, hyphenated, short, no stop words.",
"cta": "One clear call to action for this page, written as two parts separated by a line break. First, a short frictionless button label of about three to six words, no keyword stuffing (for example 'Schedule a free consultation'). Second, one warm supporting sentence beneath it that naturally names the service and the location so the visitor knows exactly what they are booking (for example 'Let's talk about couples counseling in Worcester and what you are hoping to work on.'). The keyword belongs in that supporting sentence, worked in naturally, never in the button label.",
"image": "A recommended image for this page (what it shows, the mood, the subject). If the practitioner described an image they are already using, refine and confirm that image instead of suggesting a different one.",
"image_alt": "The alt text for the image. Write an accurate, specific description of what the image actually shows, and work the target keyword, or a close natural variant of it, into that description. It must read as a genuine description, never a keyword pasted onto nothing. Pattern to follow: a description of the subject and setting that names the service and location, for example 'Jordan Lee, LMFT, a couples counselor in Worcester, MA, sitting in a sunlit office with two armchairs.'",
"faq": [
{ "question": "spoken-style question", "answer": "snippet-length answer" }
]
}
EEAT signals:
- A therapy website is a Your Money or Your Life topic, so Google weighs signals of a real, qualified practitioner heavily. Surface the practitioner's credentials in the copy where it reads naturally.
- Whenever you name the practitioner, follow the name with their credential, for example "Jordan Lee, LMFT" rather than just "Jordan Lee". This applies in the meta description, the page title where it fits, the image alt text, and FAQ answers.
- Build every EEAT signal only from the practice details actually provided: the practitioner name, the credential or license, the location, who they help, and how they see clients. Never invent license numbers, years in practice, certifications, awards, professional memberships, or any qualification that was not given to you. If only a credential abbreviation is provided with no license number, use the abbreviation alone.
Subtopics rules:
- Give 3 to 5 subtopics. Each "h2" is a natural section heading the page could include, in plain language, reflecting what someone searching this keyword wants to understand. Phrase it as a heading or noun phrase, not a question; questions belong in the FAQ, not here.
- These are options, not requirements. The practitioner uses the ones that fit their page and skips the rest, so make each one genuinely useful and specific to this page's topic and keyword.
- Where a subtopic naturally breaks into distinct sub-points, include 2 to 4 "h3" sub-headings under it. Where it does not, set "h3" to an empty array. Do not force sub-headings.
- Do not keyword-stuff these headings. They exist to help the practitioner cover the topic well.
FAQ rules (this is the voice search section, and it matters):
- Write 5 to 7 questions. Never fewer than 5.
- Each question is phrased the way a real client would say it out loud to a voice assistant: conversational, complete, usually starting with how, what, where, who, can, or do. Not a typed search string.
- Cover different angles: a location or "near me" question, a cost or insurance or logistics question, a "what to expect" or "is this the right fit for me" question, a process question, and so on. Make every question specific to this page's topic and keyword.
- Make at least one question an experience or qualifications question (for example how the practitioner is trained, or how they work with this concern). Its answer should state the practitioner's credential and approach in first person, drawing only on the details provided.
- One question is anchored on the exact target keyword's intent, and its answer puts the exact keyword phrase in the first sentence, said naturally.
- Every answer is 40 to 60 words. Give the direct answer first, then a little context. That is the length a voice assistant reads aloud and what Google pulls as a featured snippet.
- Write every answer in the FIRST PERSON, as the practitioner speaking directly to the prospective client (I, me, my, or we and our for a group practice). For example "I offer free fifteen-minute consultations" rather than "the therapist offers consultations." The questions stay in the client's voice; the answers are the practitioner's own voice. Warm, clear, never salesy.`;
const locs = (practice.locations || [])
.map(s => (s || "").trim())
.filter(Boolean);
const primaryLoc = locs[0] || "not given";
const otherLocs = locs.slice(1);
const user = `Practice details:
- Practice name: ${practice.practiceName || "not given"}
- Practitioner: ${practice.practitionerName || "not given"}
- Credentials / license: ${practice.credentials || "not given"}
- Primary location: ${primaryLoc}
- Additional service areas: ${otherLocs.length ? otherLocs.join("; ") : "none"}
- Sees clients: ${practice.deliveryMode || "not specified"}
- Who they help: ${practice.whoYouHelp || "not given"}
Page to plan:
- Page name: ${page.name}
- Target keyword (use this exact phrase): ${page.keyword}
- Image the practitioner is already using: ${page.image ? page.image : "none provided, recommend one"}
Infer the page type from the page name and plan accordingly.`;
const raw = await callClaude(system, user, 4000);
return parsePlanJSON(raw);
}
// ============== STORAGE ==============
const PRACTICE_KEY = "seo_planner_practice_v1";
const PAGES_KEY = "seo_planner_pages_v1";
async function storageGet(key) {
try {
if (typeof window === "undefined" || !window.storage) return null;
const r = await window.storage.get(key);
return r && r.value ? JSON.parse(r.value) : null;
} catch (e) {
return null;
}
}
async function storageSet(key, value) {
try {
if (typeof window === "undefined" || !window.storage) return;
await window.storage.set(key, JSON.stringify(value));
} catch (e) {}
}
// ============== ROW EXPORT ==============
// Column order matches the user's Google Sheet, left to right.
const COLUMNS = [
"Meta Description", "Page Title", "Page Header H1", "Subtopics to Cover",
"Slug Optimization", "Call to Action", "Image Description", "FAQ / Voice Search Optimization",
];
// One cell of tab-separated data must contain no tabs and no line breaks, or
// Google Sheets reads it as extra columns or extra rows. This collapses any
// stray whitespace so every cell stays a single clean value.
function cleanCell(v) {
return String(v == null ? "" : v).replace(/[\t\r\n]+/g, " ").trim();
}
function subtopicsToText(list) {
return (Array.isArray(list) ? list : []).map(s => {
const h3s = (Array.isArray(s.h3) ? s.h3 : []).filter(Boolean);
const h3part = h3s.length ? ` (${h3s.join("; ")})` : "";
return (s.h2 || "") + h3part;
}).join(" | ");
}
function faqToText(list) {
return (Array.isArray(list) ? list : [])
.map((f, i) => `${i + 1}. ${f.question || ""} ${f.answer || ""}`)
.join(" | ");
}
function planToCells(page) {
const p = page.plan || {};
const imageText = `Image: ${p.image || ""} | Alt text: ${p.image_alt || ""}`;
// order must match COLUMNS, one cell per column in the user's sheet
return [
p.meta_description || "", // Meta Description
p.page_title || "", // Page Title
p.h1 || "", // Page Header H1
subtopicsToText(p.subtopics), // Subtopics to Cover
p.slug || "", // Slug Optimization
p.cta || "", // Call to Action
imageText, // Image Description
faqToText(p.faq), // FAQ / Voice Search Optimization
];
}
// Copies one or more rows as tab-separated text. Every cell is a single line
// (no tabs, no line breaks), so Google Sheets pastes each row as exactly one
// row with every cell landing in its own column.
async function copyCellRows(rowsOfCells) {
const tsv = rowsOfCells
.map(cells => cells.map(cleanCell).join("\t"))
.join("\n");
try {
await navigator.clipboard.writeText(tsv);
return true;
} catch (e) { /* fall through to the legacy method */ }
try {
const ta = document.createElement("textarea");
ta.value = tsv;
ta.style.position = "fixed";
ta.style.left = "-99999px";
ta.style.top = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand("copy"); } catch (e2) { ok = false; }
document.body.removeChild(ta);
return ok;
} catch (e) {
return false;
}
}
// ============== SMALL COMPONENTS ==============
function CopyButton({ text, small }) {
const [done, setDone] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(text || "");
setDone(true);
setTimeout(() => setDone(false), 1600);
} catch (e) {}
};
return (
);
}
function TextField({ label, value, onChange, placeholder, hint }) {
return (
Every page plan is written from these details, so the more specific you are, the better the result.
We only use what you give us here; nothing gets invented.
set("practiceName", v)}
placeholder="e.g., Cedar Creek Therapy" />
set("practitionerName", v)}
placeholder="e.g., Maya Ellison" />
set("credentials", v)}
placeholder="e.g., LMFT, License #12345" />
{/* locations */}
City and state, or a service area. The first is your primary location; homepage, about, and contact pages anchor there.
List each page on your website and the one keyword you want it to rank for. You choose the keyword;
the tool builds the plan around it. The image is optional: describe one you already have, or leave it blank
and we will recommend one.
{pages.length === 0 && (
No pages yet. Add your first one to get started.
)}
{pages.map((p, idx) => (
PAGE {idx + 1}
updatePage(p.id, { name: v })}
placeholder="e.g., Anxiety Therapy" />
updatePage(p.id, { keyword: v })}
placeholder="e.g., anxiety therapist portland" />
updatePage(p.id, { image: v })}
placeholder="Describe it, e.g., a calm sunlit office with two armchairs. Leave blank for a recommendation."
/>
{p.plan && (
Plan generated. Editing the keyword means you should regenerate on step 3.
)}
))}
Add a page setBulkOpen(!bulkOpen)}> Add several at once
{bulkOpen && (
One page per line, as page name, keyword (a comma or a tab between them). Keyword is optional and can be added later.
)}
Basics
Next: page plans
);
}
// ============== OUTPUT FIELD ==============
function OutputField({ label, value, accent, count, countTarget }) {
return (
Generate a full SEO plan for each page: meta description, title, H1, slug, call to action, image and alt text,
and a voice-search FAQ. Copy any field, or grab the whole row for your spreadsheet.
{pages.length === 0 && (
No pages yet. Go back to step 2 to add your pages and keywords.