| 1 |
import React from "react"; |
| 2 |
import { useState, useEffect, useRef } from "@wordpress/element"; |
| 3 |
import { __, _n, sprintf } from "@wordpress/i18n"; |
| 4 |
import { decodeEntities } from "@wordpress/html-entities"; |
| 5 |
import { Sparkle, Spinner, IconClose, IconCheck, IconDoc } from "./icons"; |
| 6 |
import { IconDrag } from "../../icons"; |
| 7 |
|
| 8 |
/* ---------- AI badge ---------- */ |
| 9 |
export const AiBadge = ({ label }) => ( |
| 10 |
<span className="betterdocs-sd-badge"> |
| 11 |
<Sparkle size={14} /> {label || __("AI-powered — no API key needed", "betterdocs")} |
| 12 |
</span> |
| 13 |
); |
| 14 |
|
| 15 |
/* ---------- "Generate Sample Docs" sparkle pill ---------- */ |
| 16 |
export const GenerateBtn = ({ children, onClick, disabled, block }) => ( |
| 17 |
<button |
| 18 |
type="button" |
| 19 |
className={`betterdocs-sd-gen-btn${block ? " betterdocs-sd-gen-btn--block" : ""}`} |
| 20 |
onClick={disabled ? undefined : onClick} |
| 21 |
disabled={disabled} |
| 22 |
> |
| 23 |
<Sparkle size={16} /> {children || __("Generate Sample Docs", "betterdocs")} |
| 24 |
</button> |
| 25 |
); |
| 26 |
|
| 27 |
/* ---------- Detecting view ---------- */ |
| 28 |
export const DetectingView = ({ title, sub }) => ( |
| 29 |
<div className="betterdocs-sd-detecting betterdocs-sd-fade"> |
| 30 |
<div className="betterdocs-sd-detecting-orb"> |
| 31 |
<Spinner size={64} /> |
| 32 |
<span className="betterdocs-sd-detecting-spark"><Sparkle size={24} /></span> |
| 33 |
</div> |
| 34 |
<div className="betterdocs-sd-detecting-title"> |
| 35 |
{title || __("Analyzing your site…", "betterdocs")} |
| 36 |
</div> |
| 37 |
<div className="betterdocs-sd-detecting-sub"> |
| 38 |
{sub || __("Detecting your site type and the topics your visitors ask about.", "betterdocs")} |
| 39 |
</div> |
| 40 |
<div className="betterdocs-sd-detecting-dots"> |
| 41 |
{[0, 1, 2].map((i) => ( |
| 42 |
<span key={i} style={{ animationDelay: `${i * 0.18}s` }} /> |
| 43 |
))} |
| 44 |
</div> |
| 45 |
</div> |
| 46 |
); |
| 47 |
|
| 48 |
/* ---------- Editable category chips ---------- */ |
| 49 |
export function CategoryChips({ cats, setCats, emptyText }) { |
| 50 |
const remove = (id) => setCats((cs) => cs.filter((c) => c.id !== id)); |
| 51 |
|
| 52 |
if (!cats.length) { |
| 53 |
return ( |
| 54 |
<p className="betterdocs-sd-chips-empty"> |
| 55 |
{emptyText || __("All suggestions removed. Reload this page to start over with a fresh set.", "betterdocs")} |
| 56 |
</p> |
| 57 |
); |
| 58 |
} |
| 59 |
|
| 60 |
return ( |
| 61 |
<div className="betterdocs-sd-chips"> |
| 62 |
{cats.map((c) => ( |
| 63 |
<span key={c.id} className="betterdocs-sd-chip betterdocs-sd-pop"> |
| 64 |
{decodeEntities(c.name)} |
| 65 |
<button |
| 66 |
type="button" |
| 67 |
className="betterdocs-sd-chip-remove" |
| 68 |
title={__("Remove", "betterdocs")} |
| 69 |
onClick={() => remove(c.id)} |
| 70 |
> |
| 71 |
<IconClose size={14} sw={2.4} /> |
| 72 |
</button> |
| 73 |
</span> |
| 74 |
))} |
| 75 |
</div> |
| 76 |
); |
| 77 |
} |
| 78 |
|
| 79 |
/* ---------- Quota note ---------- */ |
| 80 |
export const QuotaNote = () => ( |
| 81 |
<div className="betterdocs-sd-quota betterdocs-sd-fade"> |
| 82 |
<span className="betterdocs-sd-quota-icon"><Sparkle size={16} /></span> |
| 83 |
<div className="betterdocs-sd-quota-text"> |
| 84 |
{__("You've used your free AI generation. Add your OpenAI API key in Settings to generate more — your suggested categories are saved below.", "betterdocs")} |
| 85 |
</div> |
| 86 |
</div> |
| 87 |
); |
| 88 |
|
| 89 |
/* ---------- Detected profile ---------- */ |
| 90 |
export function ProfileBody({ profile, cats, setCats, quota, contentType = "docs", intent = "", setIntent }) { |
| 91 |
const site = profile?.site || {}; |
| 92 |
// Prefer the server's content-aware niche label (e.g. "AI / LLM platform"); |
| 93 |
// fall back to the plugin-type label for older profiles. |
| 94 |
const typeLabel = decodeEntities((profile?.niche_label && String(profile.niche_label)) || humanType(profile?.type)); |
| 95 |
const signals = (profile?.signals?.active_plugins || []).slice(0, 5); |
| 96 |
const storeName = decodeEntities(site.title || __("your site", "betterdocs")); |
| 97 |
// Content-type-specific scan blurb: docs vs FAQ vs WooCommerce product FAQ. |
| 98 |
const blurb = |
| 99 |
contentType === "product_faq" |
| 100 |
? __("We scanned %s and prepared starter FAQs for your product categories, from what your products actually say.", "betterdocs") |
| 101 |
: contentType === "faq" |
| 102 |
? __("We scanned %s and prepared a starter list of FAQs tailored to what your visitors usually ask.", "betterdocs") |
| 103 |
: __("We scanned %s and prepared a starter knowledge base tailored to what your visitors usually ask.", "betterdocs"); |
| 104 |
// Both FAQ tabs now work the same way: real detected topics as chips (the General |
| 105 |
// tab's site subjects, the WooCommerce tab's product categories), with the AI |
| 106 |
// designing the groups and writing the questions. Only docs differs. |
| 107 |
const isGeneralFaq = contentType !== "docs"; |
| 108 |
const showIntent = !!setIntent && (contentType === "docs" || isGeneralFaq); |
| 109 |
const intentCopy = isGeneralFaq |
| 110 |
? contentType === "product_faq" |
| 111 |
? { |
| 112 |
label: __("What should these product FAQs answer? (optional)", "betterdocs"), |
| 113 |
placeholder: __("e.g. Sizing and care questions shoppers ask before buying our apparel.", "betterdocs"), |
| 114 |
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"), |
| 115 |
} |
| 116 |
: { |
| 117 |
label: __("What should these FAQs answer? (optional)", "betterdocs"), |
| 118 |
placeholder: __("e.g. Questions advertisers ask before signing up — pricing, billing, campaign limits.", "betterdocs"), |
| 119 |
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"), |
| 120 |
} |
| 121 |
: { |
| 122 |
label: __("What should this knowledge base be about? (optional)", "betterdocs"), |
| 123 |
placeholder: __("e.g. A knowledge base for our billboard advertising platform, for both advertisers and operators.", "betterdocs"), |
| 124 |
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"), |
| 125 |
}; |
| 126 |
// Count of detected subjects (chips). Docs used to also show an article count |
| 127 |
// ("3 categories, 9 docs"), but those were placeholder seed articles — the deep |
| 128 |
// outline flow right-sizes the real KB, so the number never matched. All three tabs |
| 129 |
// now show just the detected-topic count. |
| 130 |
const catCount = sprintf(_n("%d topic", "%d topics", cats.length, "betterdocs"), cats.length); |
| 131 |
|
| 132 |
return ( |
| 133 |
<div className="betterdocs-sd-profile betterdocs-sd-fade"> |
| 134 |
<div> |
| 135 |
<div className="betterdocs-sd-profile-head"> |
| 136 |
<h3 className="betterdocs-sd-profile-title"> |
| 137 |
{sprintf(__("We detected %s", "betterdocs"), withArticle(typeLabel))} |
| 138 |
</h3> |
| 139 |
</div> |
| 140 |
<p className="betterdocs-sd-profile-blurb"> |
| 141 |
{sprintf(blurb, storeName)} |
| 142 |
</p> |
| 143 |
{signals.length > 0 && ( |
| 144 |
<div className="betterdocs-sd-signals"> |
| 145 |
{signals.map((s) => ( |
| 146 |
<span key={s} className="betterdocs-sd-signal"> |
| 147 |
<span className="betterdocs-sd-signal-dot" /> |
| 148 |
{s} |
| 149 |
</span> |
| 150 |
))} |
| 151 |
</div> |
| 152 |
)} |
| 153 |
</div> |
| 154 |
|
| 155 |
{showIntent && ( |
| 156 |
<div className="betterdocs-sd-intent"> |
| 157 |
<label className="betterdocs-sd-intent-label" htmlFor="betterdocs-sd-intent-input"> |
| 158 |
{intentCopy.label} |
| 159 |
</label> |
| 160 |
<textarea |
| 161 |
id="betterdocs-sd-intent-input" |
| 162 |
className="betterdocs-sd-intent-input" |
| 163 |
rows={2} |
| 164 |
maxLength={400} |
| 165 |
placeholder={intentCopy.placeholder} |
| 166 |
value={intent} |
| 167 |
onChange={(e) => setIntent(e.target.value)} |
| 168 |
/> |
| 169 |
<p className="betterdocs-sd-intent-hint">{intentCopy.hint}</p> |
| 170 |
</div> |
| 171 |
)} |
| 172 |
|
| 173 |
<div> |
| 174 |
{/* Hide the whole "Topics we detected" section when there are no chips — |
| 175 |
the same for docs, sample FAQ and sample product FAQ. An empty state |
| 176 |
("No specific topics detected" / "All suggestions removed") reads as a |
| 177 |
failure, but it isn't: the AI still designs the content from the full |
| 178 |
site content digest. So we omit the section and let the intent box + |
| 179 |
generation speak for it. */} |
| 180 |
{cats.length > 0 && ( |
| 181 |
<> |
| 182 |
<div className="betterdocs-sd-profile-subhead"> |
| 183 |
<span className="betterdocs-sd-profile-subtitle"> |
| 184 |
{__("Topics we detected", "betterdocs")} |
| 185 |
</span> |
| 186 |
<span className="betterdocs-sd-profile-count"> |
| 187 |
{catCount} |
| 188 |
</span> |
| 189 |
</div> |
| 190 |
<CategoryChips cats={cats} setCats={setCats} /> |
| 191 |
<p className="betterdocs-sd-intent-hint"> |
| 192 |
{contentType === "product_faq" |
| 193 |
? __("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") |
| 194 |
: contentType === "docs" |
| 195 |
? __("These are subjects found on your site. BetterDocs AI designs your knowledge base around them — remove a topic to keep it out.", "betterdocs") |
| 196 |
: __("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")} |
| 197 |
</p> |
| 198 |
</> |
| 199 |
)} |
| 200 |
<p className="betterdocs-sd-privacy-note"> |
| 201 |
{contentType === "product_faq" |
| 202 |
? __("We send your site type and category suggestions to BetterDocs AI. No customer personal data is included.", "betterdocs") |
| 203 |
: __("We send your site type, detected topics and short public content excerpts to BetterDocs AI. No customer personal data is included.", "betterdocs")} |
| 204 |
</p> |
| 205 |
</div> |
| 206 |
|
| 207 |
{quota && <QuotaNote />} |
| 208 |
</div> |
| 209 |
); |
| 210 |
} |
| 211 |
|
| 212 |
/* ---------- Generating progress ---------- */ |
| 213 |
export function useGenProgress(cats, ready, onDone) { |
| 214 |
const [rows, setRows] = useState(() => cats.map((c) => ({ id: c.id, state: "pending", written: 0 }))); |
| 215 |
const [pct, setPct] = useState(0); |
| 216 |
const [seqDone, setSeqDone] = useState(false); // the "writing" animation has played through every category |
| 217 |
const doneRef = useRef(onDone); |
| 218 |
doneRef.current = onDone; |
| 219 |
const finished = useRef(false); |
| 220 |
|
| 221 |
// The writing animation fills the bar the whole way; once it reaches 100% the |
| 222 |
// "Finalizing" step (below) covers the wait for the real generation result. |
| 223 |
const CAP = 100; |
| 224 |
|
| 225 |
// Category "writing" animation — runs once on mount, drives the bar to CAP. |
| 226 |
useEffect(() => { |
| 227 |
let alive = true; |
| 228 |
const timers = []; |
| 229 |
const after = (ms, fn) => { |
| 230 |
const t = setTimeout(() => alive && fn(), ms); |
| 231 |
timers.push(t); |
| 232 |
}; |
| 233 |
setRows(cats.map((c) => ({ id: c.id, state: "pending", written: 0 }))); |
| 234 |
setPct(0); |
| 235 |
setSeqDone(false); |
| 236 |
finished.current = false; |
| 237 |
const total = cats.length || 1; |
| 238 |
|
| 239 |
const runCat = (i) => { |
| 240 |
if (i >= cats.length) { |
| 241 |
setSeqDone(true); |
| 242 |
return; |
| 243 |
} |
| 244 |
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "creating" } : x))); |
| 245 |
after(480, () => { |
| 246 |
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "writing" } : x))); |
| 247 |
const arts = cats[i].articles.length; |
| 248 |
// FAQ topics carry no seeded questions (the AI decides how many each |
| 249 |
// subject warrants), so there is nothing to count up — hold the row in |
| 250 |
// "writing" for a beat, then mark it done. |
| 251 |
if (!arts) { |
| 252 |
after(900, () => { |
| 253 |
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "done" } : x))); |
| 254 |
setPct(Math.round(((i + 1) / total) * CAP)); |
| 255 |
after(280, () => runCat(i + 1)); |
| 256 |
}); |
| 257 |
return; |
| 258 |
} |
| 259 |
const writeOne = (w) => { |
| 260 |
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, written: w } : x))); |
| 261 |
if (w < arts) after(300, () => writeOne(w + 1)); |
| 262 |
else |
| 263 |
after(360, () => { |
| 264 |
setRows((r) => r.map((x, idx) => (idx === i ? { ...x, state: "done" } : x))); |
| 265 |
setPct(Math.round(((i + 1) / total) * CAP)); |
| 266 |
after(280, () => runCat(i + 1)); |
| 267 |
}); |
| 268 |
}; |
| 269 |
after(320, () => writeOne(1)); |
| 270 |
}); |
| 271 |
}; |
| 272 |
after(450, () => runCat(0)); |
| 273 |
return () => { |
| 274 |
alive = false; |
| 275 |
timers.forEach(clearTimeout); |
| 276 |
}; |
| 277 |
}, []); |
| 278 |
|
| 279 |
// Completion: once the writing animation has played through (bar at 100%), |
| 280 |
// advance to the preview the instant the real result is ready. While waiting, |
| 281 |
// the bar rests at 100% and the "Finalizing" step shows a spinner. |
| 282 |
useEffect(() => { |
| 283 |
if (finished.current || !seqDone || !ready) return; |
| 284 |
finished.current = true; |
| 285 |
setPct(100); |
| 286 |
const t = setTimeout(() => doneRef.current && doneRef.current(), 300); |
| 287 |
return () => clearTimeout(t); |
| 288 |
}, [seqDone, ready]); |
| 289 |
|
| 290 |
// `finalizing`: the writing animation has played through (bar at 100%) but the |
| 291 |
// real generation result hasn't arrived yet. |
| 292 |
// `done`: the animation finished AND the real result is ready — the terminal |
| 293 |
// 100% state, just before we transition to the preview. |
| 294 |
return { rows, pct, finalizing: seqDone && !ready, done: seqDone && ready }; |
| 295 |
} |
| 296 |
|
| 297 |
export function GeneratingBody({ cats, rows, pct, finalizing, done = false, contentType = "docs" }) { |
| 298 |
const rowFor = (c) => rows.find((r) => r.id === c.id) || { state: "pending", written: 0 }; |
| 299 |
// Content-type-aware progress copy: a knowledge base, an FAQ list, and product |
| 300 |
// FAQs are different things, so the label must match what's being generated. |
| 301 |
// `ready` is the terminal 100% message shown once generation is complete — it |
| 302 |
// must not fall back to "Building…" in the moment before we switch screens. |
| 303 |
const COPY = { |
| 304 |
docs: { building: __("Building your knowledge base…", "betterdocs"), finalizing: __("Finalizing your knowledge base…", "betterdocs"), ready: __("Knowledge base is ready", "betterdocs") }, |
| 305 |
faq: { building: __("Building your FAQs…", "betterdocs"), finalizing: __("Finalizing your FAQs…", "betterdocs"), ready: __("FAQs are ready", "betterdocs") }, |
| 306 |
product_faq: { building: __("Building your product FAQs…", "betterdocs"), finalizing: __("Finalizing your product FAQs…", "betterdocs"), ready: __("Product FAQs are ready", "betterdocs") }, |
| 307 |
}; |
| 308 |
const copy = COPY[contentType] || COPY.docs; |
| 309 |
return ( |
| 310 |
<div className="betterdocs-sd-fade"> |
| 311 |
<div className="betterdocs-sd-progress-head"> |
| 312 |
<span className="betterdocs-sd-progress-label"> |
| 313 |
{done ? copy.ready : (finalizing || pct === 100) ? copy.finalizing : copy.building} |
| 314 |
</span> |
| 315 |
<span className="betterdocs-sd-progress-pct">{pct}%</span> |
| 316 |
</div> |
| 317 |
<div className="betterdocs-sd-progress-track"> |
| 318 |
<div className="betterdocs-sd-progress-fill" style={{ width: `${pct}%` }} /> |
| 319 |
</div> |
| 320 |
|
| 321 |
<div className="betterdocs-sd-progress-rows"> |
| 322 |
{cats.map((c) => { |
| 323 |
const r = rowFor(c); |
| 324 |
const done = r.state === "done"; |
| 325 |
const live = r.state === "creating" || r.state === "writing"; |
| 326 |
// A FAQ topic has no planned question count (the AI decides), so its |
| 327 |
// rows can't show k/N — they'd read "Writing 1/0 questions…". |
| 328 |
const planned = c.articles.length; |
| 329 |
const sub = |
| 330 |
r.state === "creating" |
| 331 |
? sprintf(__("Creating %s…", "betterdocs"), decodeEntities(c.name)) |
| 332 |
: r.state === "writing" |
| 333 |
? (!planned |
| 334 |
? __("Writing questions…", "betterdocs") |
| 335 |
: contentType === "docs" |
| 336 |
? sprintf(__("Writing %1$d/%2$d docs…", "betterdocs"), r.written, planned) |
| 337 |
: sprintf(__("Writing %1$d/%2$d questions…", "betterdocs"), r.written, planned)) |
| 338 |
: done |
| 339 |
? (!planned |
| 340 |
? __("Questions ready", "betterdocs") |
| 341 |
: contentType === "docs" |
| 342 |
? sprintf(_n("%d doc ready", "%d docs ready", planned, "betterdocs"), planned) |
| 343 |
: sprintf(_n("%d question ready", "%d questions ready", planned, "betterdocs"), planned)) |
| 344 |
: __("Queued", "betterdocs"); |
| 345 |
return ( |
| 346 |
<div |
| 347 |
key={c.id} |
| 348 |
className={`betterdocs-sd-progress-row${live ? " is-live" : ""}${r.state === "pending" ? " is-pending" : ""}`} |
| 349 |
> |
| 350 |
<div className="betterdocs-sd-progress-row-meta"> |
| 351 |
<div className="betterdocs-sd-progress-row-name">{decodeEntities(c.name)}</div> |
| 352 |
<div className={`betterdocs-sd-progress-row-sub${done ? " is-done" : ""}`}>{sub}</div> |
| 353 |
</div> |
| 354 |
<div className="betterdocs-sd-progress-row-state"> |
| 355 |
{done ? ( |
| 356 |
<span className="betterdocs-sd-progress-check"><IconCheck size={14} sw={3} /></span> |
| 357 |
) : live ? ( |
| 358 |
<Spinner size={20} /> |
| 359 |
) : ( |
| 360 |
<span className="betterdocs-sd-progress-dot" /> |
| 361 |
)} |
| 362 |
</div> |
| 363 |
</div> |
| 364 |
); |
| 365 |
})} |
| 366 |
</div> |
| 367 |
|
| 368 |
{(finalizing || pct === 100) && ( |
| 369 |
<div className={`betterdocs-sd-progress-finalizing${done ? " is-done" : ""}`}> |
| 370 |
<span className="betterdocs-sd-progress-finalizing-icon"> |
| 371 |
{done ? <IconCheck size={14} sw={3} /> : <Spinner size={18} />} |
| 372 |
</span> |
| 373 |
<span className="betterdocs-sd-progress-finalizing-text"> |
| 374 |
{done ? copy.ready : copy.finalizing} |
| 375 |
</span> |
| 376 |
</div> |
| 377 |
)} |
| 378 |
</div> |
| 379 |
); |
| 380 |
} |
| 381 |
|
| 382 |
/* ---------- Preview groups ---------- */ |
| 383 |
export function PreviewGroups({ cats, contentType = "docs" }) { |
| 384 |
return ( |
| 385 |
<div className="betterdocs-sd-preview"> |
| 386 |
{cats.map((c) => ( |
| 387 |
<div key={c.id} className="betterdocs-sd-preview-group betterdocs-sd-fade"> |
| 388 |
<div className="betterdocs-sd-preview-head"> |
| 389 |
<span className="betterdocs-sd-preview-name">{decodeEntities(c.name)}</span> |
| 390 |
{/* The consolidated store-wide group shows on every product page; label it |
| 391 |
so the owner knows it isn't tied to one product category. */} |
| 392 |
{c.all_products && ( |
| 393 |
<span className="betterdocs-sd-preview-tag" title={__("Shown on every product page", "betterdocs")}> |
| 394 |
{__("All products", "betterdocs")} |
| 395 |
</span> |
| 396 |
)} |
| 397 |
<span className="betterdocs-sd-preview-badge"> |
| 398 |
{itemCount(c.articles.length, contentType)} |
| 399 |
</span> |
| 400 |
</div> |
| 401 |
<div> |
| 402 |
{c.articles.map((a, i) => ( |
| 403 |
<div key={i} className="betterdocs-sd-preview-article"> |
| 404 |
<span className="betterdocs-sd-preview-article-icon"><IconDoc size={16} /></span> |
| 405 |
<span className="betterdocs-sd-preview-article-title">{articleTitle(a)}</span> |
| 406 |
<span className="betterdocs-sd-preview-article-draft">{__("Draft", "betterdocs")}</span> |
| 407 |
</div> |
| 408 |
))} |
| 409 |
</div> |
| 410 |
</div> |
| 411 |
))} |
| 412 |
</div> |
| 413 |
); |
| 414 |
} |
| 415 |
|
| 416 |
/* ---------- Populated docs — All Docs grid look (read-only) ---------- |
| 417 |
Mirrors the dashboard category grid (betterdocs-row > col-md-4 > |
| 418 |
category-wrap). Read-only: drag handle is shown but there are no hover |
| 419 |
action buttons and no "Add New Doc" footer; height follows content. */ |
| 420 |
export function PopulatedDocs({ cats }) { |
| 421 |
return ( |
| 422 |
<div className="betterdocs-sd-populated"> |
| 423 |
<div className="betterdocs-sd-populated-header"> |
| 424 |
<div className="betterdocs-sd-populated-header-left"> |
| 425 |
<h2 className="betterdocs-sd-populated-title">{__("All Docs", "betterdocs")}</h2> |
| 426 |
<span className="betterdocs-sd-populated-badge">{__("AI-generated", "betterdocs")}</span> |
| 427 |
</div> |
| 428 |
</div> |
| 429 |
<div className="betterdocs-sd-populated-grid betterdocs-row"> |
| 430 |
{cats.map((c) => ( |
| 431 |
<div key={c.id} className="betterdocs-col betterdocs-col-md-4 betterdocs-sd-fade"> |
| 432 |
<div className="betterdocs-sd-cat-wrap"> |
| 433 |
<div className="betterdocs-sd-cat-header"> |
| 434 |
<h3 className="betterdocs-sd-cat-name">{decodeEntities(c.name)}</h3> |
| 435 |
</div> |
| 436 |
<ul className="betterdocs-sd-cat-list"> |
| 437 |
{c.articles.map((a, i) => ( |
| 438 |
<li key={i}> |
| 439 |
<span className="betterdocs-sd-doc-drag"> |
| 440 |
<IconDrag /> |
| 441 |
</span> |
| 442 |
<span className="betterdocs-sd-doc-title">{articleTitle(a)}</span> |
| 443 |
</li> |
| 444 |
))} |
| 445 |
</ul> |
| 446 |
</div> |
| 447 |
</div> |
| 448 |
))} |
| 449 |
</div> |
| 450 |
</div> |
| 451 |
); |
| 452 |
} |
| 453 |
|
| 454 |
/* ---------- helpers ---------- */ |
| 455 |
export function articleTitle(a) { |
| 456 |
return decodeEntities(typeof a === "string" ? a : a?.title || ""); |
| 457 |
} |
| 458 |
|
| 459 |
/* Count label matched to what's being generated and correctly singular/plural: |
| 460 |
docs → "1 doc" / "3 docs"; FAQ & product FAQ → "1 question" / "3 questions". */ |
| 461 |
export function itemCount(n, contentType = "docs") { |
| 462 |
return contentType === "docs" |
| 463 |
? sprintf(_n("%d doc", "%d docs", n, "betterdocs"), n) |
| 464 |
: sprintf(_n("%d question", "%d questions", n, "betterdocs"), n); |
| 465 |
} |
| 466 |
|
| 467 |
function humanType(type) { |
| 468 |
const map = { |
| 469 |
ecommerce: __("WooCommerce store", "betterdocs"), |
| 470 |
course_lms: __("course / LMS site", "betterdocs"), |
| 471 |
digital_downloads: __("digital downloads store", "betterdocs"), |
| 472 |
membership: __("membership site", "betterdocs"), |
| 473 |
community: __("community site", "betterdocs"), |
| 474 |
business_site: __("business site", "betterdocs"), |
| 475 |
general: __("website", "betterdocs"), |
| 476 |
}; |
| 477 |
return map[type] || map.general; |
| 478 |
} |
| 479 |
|
| 480 |
/* Prefix the right indefinite article ("a"/"an") for a human label, so e.g. |
| 481 |
"AI / LLM platform" reads "an AI / LLM platform" and "WooCommerce store" |
| 482 |
reads "a WooCommerce store". */ |
| 483 |
function withArticle(label) { |
| 484 |
const l = String(label || "").trim(); |
| 485 |
if (!l) return __("a website", "betterdocs"); |
| 486 |
return /^[aeiou]/i.test(l) ? `an ${l}` : `a ${l}`; |
| 487 |
} |
| 488 |
|