{__("You've used your free AI generation. Add your OpenAI API key in Settings to generate more — your suggested categories are saved below.", "betterdocs")}
);
/* ---------- Detected profile ---------- */
export function ProfileBody({ profile, cats, setCats, quota, contentType = "docs", intent = "", setIntent }) {
const site = profile?.site || {};
// Prefer the server's content-aware niche label (e.g. "AI / LLM platform");
// fall back to the plugin-type label for older profiles.
const typeLabel = decodeEntities((profile?.niche_label && String(profile.niche_label)) || humanType(profile?.type));
const signals = (profile?.signals?.active_plugins || []).slice(0, 5);
const storeName = decodeEntities(site.title || __("your site", "betterdocs"));
// Content-type-specific scan blurb: docs vs FAQ vs WooCommerce product FAQ.
const blurb =
contentType === "product_faq"
? __("We scanned %s and prepared starter FAQs for your product categories, from what your products actually say.", "betterdocs")
: contentType === "faq"
? __("We scanned %s and prepared a starter list of FAQs tailored to what your visitors usually ask.", "betterdocs")
: __("We scanned %s and prepared a starter knowledge base tailored to what your visitors usually ask.", "betterdocs");
// Both FAQ tabs now work the same way: real detected topics as chips (the General
// tab's site subjects, the WooCommerce tab's product categories), with the AI
// designing the groups and writing the questions. Only docs differs.
const isGeneralFaq = contentType !== "docs";
const showIntent = !!setIntent && (contentType === "docs" || isGeneralFaq);
const intentCopy = isGeneralFaq
? contentType === "product_faq"
? {
label: __("What should these product FAQs answer? (optional)", "betterdocs"),
placeholder: __("e.g. Sizing and care questions shoppers ask before buying our apparel.", "betterdocs"),
hint: __("BetterDocs AI writes an FAQ for each product category, from your real products — and attaches it to that category so it shows on those product pages.", "betterdocs"),
}
: {
label: __("What should these FAQs answer? (optional)", "betterdocs"),
placeholder: __("e.g. Questions advertisers ask before signing up — pricing, billing, campaign limits.", "betterdocs"),
hint: __("BetterDocs AI groups the questions by subject the way a real FAQ page does, and answers each in a few sentences. Add a line to steer it.", "betterdocs"),
}
: {
label: __("What should this knowledge base be about? (optional)", "betterdocs"),
placeholder: __("e.g. A knowledge base for our billboard advertising platform, for both advertisers and operators.", "betterdocs"),
hint: __("BetterDocs AI designs the full structure — an Introduction, a Quickstart, and in-depth guides — from your site content. Add a sentence to steer it.", "betterdocs"),
};
// Count of detected subjects (chips). Docs used to also show an article count
// ("3 categories, 9 docs"), but those were placeholder seed articles — the deep
// outline flow right-sizes the real KB, so the number never matched. All three tabs
// now show just the detected-topic count.
const catCount = sprintf(_n("%d topic", "%d topics", cats.length, "betterdocs"), cats.length);
return (
{/* Hide the whole "Topics we detected" section when there are no chips —
the same for docs, sample FAQ and sample product FAQ. An empty state
("No specific topics detected" / "All suggestions removed") reads as a
failure, but it isn't: the AI still designs the content from the full
site content digest. So we omit the section and let the intent box +
generation speak for it. */}
{cats.length > 0 && (
<>
{__("Topics we detected", "betterdocs")}
{catCount}
{contentType === "product_faq"
? __("These are your product categories. BetterDocs AI writes an FAQ for each one from your real products — remove a category to keep it out.", "betterdocs")
: contentType === "docs"
? __("These are subjects found on your site. BetterDocs AI designs your knowledge base around them — remove a topic to keep it out.", "betterdocs")
: __("These are subjects found on your site. BetterDocs AI designs the FAQ groups, writes the questions and answers them — remove a topic to keep it out.", "betterdocs")}
>
)}
{contentType === "product_faq"
? __("We send your site type and category suggestions to BetterDocs AI. No customer personal data is included.", "betterdocs")
: __("We send your site type, detected topics and short public content excerpts to BetterDocs AI. No customer personal data is included.", "betterdocs")}
{quota && }
);
}
/* ---------- Generating progress ---------- */
export function useGenProgress(cats, ready, onDone) {
const [rows, setRows] = useState(() => cats.map((c) => ({ id: c.id, state: "pending", written: 0 })));
const [pct, setPct] = useState(0);
const [seqDone, setSeqDone] = useState(false); // the "writing" animation has played through every category
const doneRef = useRef(onDone);
doneRef.current = onDone;
const finished = useRef(false);
// The writing animation fills the bar the whole way; once it reaches 100% the
// "Finalizing" step (below) covers the wait for the real generation result.
const CAP = 100;
// Category "writing" animation — runs once on mount, drives the bar to CAP.
useEffect(() => {
let alive = true;
const timers = [];
const after = (ms, fn) => {
const t = setTimeout(() => alive && fn(), ms);
timers.push(t);
};
setRows(cats.map((c) => ({ id: c.id, state: "pending", written: 0 })));
setPct(0);
setSeqDone(false);
finished.current = false;
const total = cats.length || 1;
const runCat = (i) => {
if (i >= cats.length) {
setSeqDone(true);
return;
}
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "creating" } : x)));
after(480, () => {
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "writing" } : x)));
const arts = cats[i].articles.length;
// FAQ topics carry no seeded questions (the AI decides how many each
// subject warrants), so there is nothing to count up — hold the row in
// "writing" for a beat, then mark it done.
if (!arts) {
after(900, () => {
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "done" } : x)));
setPct(Math.round(((i + 1) / total) * CAP));
after(280, () => runCat(i + 1));
});
return;
}
const writeOne = (w) => {
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, written: w } : x)));
if (w < arts) after(300, () => writeOne(w + 1));
else
after(360, () => {
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "done" } : x)));
setPct(Math.round(((i + 1) / total) * CAP));
after(280, () => runCat(i + 1));
});
};
after(320, () => writeOne(1));
});
};
after(450, () => runCat(0));
return () => {
alive = false;
timers.forEach(clearTimeout);
};
}, []);
// Completion: once the writing animation has played through (bar at 100%),
// advance to the preview the instant the real result is ready. While waiting,
// the bar rests at 100% and the "Finalizing" step shows a spinner.
useEffect(() => {
if (finished.current || !seqDone || !ready) return;
finished.current = true;
setPct(100);
const t = setTimeout(() => doneRef.current && doneRef.current(), 300);
return () => clearTimeout(t);
}, [seqDone, ready]);
// `finalizing`: the writing animation has played through (bar at 100%) but the
// real generation result hasn't arrived yet.
// `done`: the animation finished AND the real result is ready — the terminal
// 100% state, just before we transition to the preview.
return { rows, pct, finalizing: seqDone && !ready, done: seqDone && ready };
}
export function GeneratingBody({ cats, rows, pct, finalizing, done = false, contentType = "docs" }) {
const rowFor = (c) => rows.find((r) => r.id === c.id) || { state: "pending", written: 0 };
// Content-type-aware progress copy: a knowledge base, an FAQ list, and product
// FAQs are different things, so the label must match what's being generated.
// `ready` is the terminal 100% message shown once generation is complete — it
// must not fall back to "Building…" in the moment before we switch screens.
const COPY = {
docs: { building: __("Building your knowledge base…", "betterdocs"), finalizing: __("Finalizing your knowledge base…", "betterdocs"), ready: __("Knowledge base is ready", "betterdocs") },
faq: { building: __("Building your FAQs…", "betterdocs"), finalizing: __("Finalizing your FAQs…", "betterdocs"), ready: __("FAQs are ready", "betterdocs") },
product_faq: { building: __("Building your product FAQs…", "betterdocs"), finalizing: __("Finalizing your product FAQs…", "betterdocs"), ready: __("Product FAQs are ready", "betterdocs") },
};
const copy = COPY[contentType] || COPY.docs;
return (
);
}
/* ---------- Preview groups ---------- */
export function PreviewGroups({ cats, contentType = "docs" }) {
return (
{cats.map((c) => (
{decodeEntities(c.name)}
{/* The consolidated store-wide group shows on every product page; label it
so the owner knows it isn't tied to one product category. */}
{c.all_products && (
{__("All products", "betterdocs")}
)}
{itemCount(c.articles.length, contentType)}
{c.articles.map((a, i) => (
{articleTitle(a)}{__("Draft", "betterdocs")}
))}
))}
);
}
/* ---------- Populated docs — All Docs grid look (read-only) ----------
Mirrors the dashboard category grid (betterdocs-row > col-md-4 >
category-wrap). Read-only: drag handle is shown but there are no hover
action buttons and no "Add New Doc" footer; height follows content. */
export function PopulatedDocs({ cats }) {
return (
{__("All Docs", "betterdocs")}
{__("AI-generated", "betterdocs")}
{cats.map((c) => (
{decodeEntities(c.name)}
{c.articles.map((a, i) => (
{articleTitle(a)}
))}
))}
);
}
/* ---------- helpers ---------- */
export function articleTitle(a) {
return decodeEntities(typeof a === "string" ? a : a?.title || "");
}
/* Count label matched to what's being generated and correctly singular/plural:
docs → "1 doc" / "3 docs"; FAQ & product FAQ → "1 question" / "3 questions". */
export function itemCount(n, contentType = "docs") {
return contentType === "docs"
? sprintf(_n("%d doc", "%d docs", n, "betterdocs"), n)
: sprintf(_n("%d question", "%d questions", n, "betterdocs"), n);
}
function humanType(type) {
const map = {
ecommerce: __("WooCommerce store", "betterdocs"),
course_lms: __("course / LMS site", "betterdocs"),
digital_downloads: __("digital downloads store", "betterdocs"),
membership: __("membership site", "betterdocs"),
community: __("community site", "betterdocs"),
business_site: __("business site", "betterdocs"),
general: __("website", "betterdocs"),
};
return map[type] || map.general;
}
/* Prefix the right indefinite article ("a"/"an") for a human label, so e.g.
"AI / LLM platform" reads "an AI / LLM platform" and "WooCommerce store"
reads "a WooCommerce store". */
function withArticle(label) {
const l = String(label || "").trim();
if (!l) return __("a website", "betterdocs");
return /^[aeiou]/i.test(l) ? `an ${l}` : `a ${l}`;
}