// SEO GUY — écrans const { useState: useS, useEffect: useE, useRef: useR } = React; function GuyThinkingLoader({ title, sub, block, compact }) { return (
SEO GUY réfléchit

{title}

{sub ?

{sub}

: null}

); } // =================== ACCUEIL (cockpit zéro effort) =================== function HomeScreen({ queue, quota: quotaProp, onValidate, onReview, publishedThisMonth, showMascot, obStep, obDismissed, onboardingIncomplete, onResumeOnboarding, liveDashboard, kpis: kpiList, busy, clientName, articlesPerWeek, gscConnected, shopifyConnected }) { const kpiData = (kpiList && kpiList.length) ? kpiList : mapKpisFromPerformance(liveDashboard?.performance); const stats = liveDashboard?.stats; const quota = quotaProp ?? liveDashboard?.quota; const next = queue[0]; const [phase, setPhase] = useS("idle"); // idle | publishing | done const greetName = (clientName || "").trim() || null; const greeting = greetName ? `Bonjour ${greetName} 👋` : "Bonjour 👋"; const needsGeneration = next && !next?.bodyHtml?.trim() && next?.raw?.status !== "content_ready" && next?.status !== "content_ready"; const quotaExhausted = quota && !quota.unlimited && !quota.canGenerate; const quotaBlocked = needsGeneration && quotaExhausted; const reviewBlocked = needsGeneration && quotaExhausted; const weeklyTarget = articlesPerWeek && articlesPerWeek > 0 ? articlesPerWeek : quota && !quota.unlimited && quota.limit > 0 ? quota.limit : 8; const compare = buildHomeCompareSnapshot({ performance: liveDashboard?.performance ?? null, weeklyTarget, articlesPerYearBeforeInstall: liveDashboard?.stats?.articlesPerYearBeforeInstall ?? 0, }); async function validate() { if (phase !== "idle" || busy || quotaBlocked || !next) return; setPhase("publishing"); try { await onValidate?.(next); setPhase("done"); setTimeout(() => setPhase("idle"), 2600); } catch (e) { setPhase("idle"); alert(e?.message || "Publication impossible"); } } return (
{onboardingIncomplete && obDismissed && obStep > 0 && ( )} Votre blog tourne tout seul.} sub="SEO GUY a rédigé, optimisé et programmé vos contenus. Il vous reste une chose à faire : valider." status="SEO GUY travaille en ce moment" /> {/* Carte centrale : le prochain article prêt */}
{phase === "done" ? (
SEO GUY

C'est publié ! 🎉

Votre article est en ligne et déjà indexé. SEO GUY prépare le suivant…

) : !next ? (
SEO GUY

{quotaExhausted ? "Quota hebdomadaire atteint" : "Aucune pépite pour l'instant"}

{quotaExhausted && quota ? <>Vous avez utilisé {quota.used}/{quota.limit} article(s) cette semaine. Revenez lundi ou passez au plan supérieur. : "Générez des titres dans Mes articles pour alimenter le pipeline."}

) : ( <>
SEO GUY {showMascot &&
J'ai trouvé une pépite pour vous 👇
}
{next.isPepite ? (
Pépite de la semaine
) : null} Prochain article prêt à publier

{next.title}

Mot-clé {next.keyword} Recherches {next.volume} Difficulté {next.difficulty} Lecture {next.readMin} min

{next.angle}

Score SEO
Excellent

Contenu optimisé juste ce qu'il faut pour avoir des résultats sur le long terme

{quota && !quota.unlimited ? ( Quota : {quota.used}/{quota.limit} cette semaine ) : null}
)}
{/* Bandeau pilotage auto (lecture seule, rassure) */}

{publishedThisMonth} articles

publiés ce mois-ci

Mardi 9 h

prochaine publication auto

{stats?.keywordsTotal ?? 0} mots-clés

suivis et priorisés

{shopifyConnected && gscConnected ? "Tout est connecté" : shopifyConnected ? "Shopify connecté" : gscConnected ? "GSC connectée" : "À connecter"}

{[shopifyConnected && "Shopify", gscConnected && "Search Console"].filter(Boolean).join(" · ") || "Shopify · Search Console"}

{/* KPIs */}
{kpiData.map((k) => )}
{/* Comparatif avant / après */}

Depuis SEO GUY

{publishedThisMonth} articles validés ce mois-ci

{compare.beforeLabel}

{compare.beforeValue}

{compare.beforeMeta}

{compare.afterLabel}

{compare.afterValue}

{compare.afterMeta}

); } // =================== MES ARTICLES =================== const STATUS_MAP = { published: { label: "Publié", tone: "published" }, scheduled: { label: "Programmé", tone: "scheduled" }, proposed: { label: "À valider", tone: "draft" }, saved: { label: "Gardé", tone: "draft" }, titled: { label: "Titre choisi", tone: "draft" }, approved: { label: "Approuvé", tone: "draft" }, generating: { label: "En génération", tone: "scheduled" }, content_ready: { label: "Brouillon", tone: "draft" }, failed: { label: "Échec", tone: "draft" }, }; function articleStatusMeta(status) { return STATUS_MAP[status] || { label: status || "—", tone: "draft" }; } function ArticleCoverPicker({ articleId, imageUrl, disabled, busy, onFile }) { const inputId = `sg-cover-${articleId}`; return (
{imageUrl ? (
) : null} { const file = e.target.files && e.target.files[0]; e.target.value = ""; if (file) onFile(file); }} />

{BLOG_COVER_HINT}

); } function ArticlesScreen({ articles, queue, titleProposals, onReview, onGenerate, saved = [], onGenerateTitles, busy, generatingId, quota, proposalTotal, onCoverUploaded }) { const [coverBusyId, setCoverBusyId] = useS(null); const [localCovers, setLocalCovers] = useS({}); function coverOf(a) { const id = a.raw?.id || a.id; return localCovers[id] || localCovers[a.id] || a.imageUrl || a.raw?.imageUrl || null; } async function uploadCover(a, file) { const id = a.raw?.id || a.id; if (!id || !file) return; setCoverBusyId(id); try { const dataUrl = await resizeBlogCoverFile(file); const res = await GuyAPI.uploadArticleImage(id, dataUrl); const url = res.article?.imageUrl || res.url; if (url) setLocalCovers((prev) => ({ ...prev, [id]: url })); onCoverUploaded?.(id, url); } catch (e) { alert(e.message || "Import du visuel impossible."); } finally { setCoverBusyId(null); } } const proposed = (titleProposals || queue || []).map((q) => ({ ...q, status: "proposed", date: "Proposé aujourd'hui", visits: 0, })); const hiddenByQuota = quota && !quota.unlimited && typeof proposalTotal === "number" ? Math.max( 0, Math.min(proposalTotal, quota.limit > 0 ? quota.limit : proposalTotal) - proposed.length, ) : 0; return (
{/* ---- Section : À valider ---- */} {proposed.length > 0 && (
SEO GUY

{proposed.length} sujet{proposed.length > 1 ? "s" : ""} à rédiger

Cliquez sur Générer — SEO GUY rédige l'article complet pour vous. {quota && !quota.unlimited ? ( <> Quota restant cette semaine : {quota.remaining}. ) : null} {hiddenByQuota > 0 ? ( <> {hiddenByQuota} autre(s) sujet(s) masqué(s) — quota hebdomadaire atteint. ) : null}

{onGenerateTitles && ( )}
{proposed.map((a) => (
{generatingId === a.id ? ( ) : ( <> {a.isPepite ? (
Pépite de la semaine
) : null}
Sujet proposé {a.score != null ? : null}

{a.title}

Mot-clé {a.keyword} Recherches {a.volume} Lecture {a.readMin} min

{a.angle}

void uploadCover(a, file)} />
)}
))}
)} {proposed.length === 0 && quota?.canGenerate && onGenerateTitles ? (

Aucun sujet proposé pour l'instant. {quota && !quota.unlimited ? ( <> Générez jusqu'à {quota.limit} titres pour remplir votre quota hebdomadaire. ) : null}

) : null} {/* ---- Section : Articles produits (publiés, programmés, brouillons) ---- */} {saved.length > 0 && (

Gardés pour plus tard

{saved.length} sujet(s)
{saved.map((s) => (

{s.title}

{s.keyword} · {s.volume}

void uploadCover(s, file)} />
))}
)}

Publiés, programmés & brouillons

{articles.length} article{articles.length > 1 ? "s" : ""}
{articles.length === 0 ? (

Aucun article produit pour l'instant. Lancez une génération pour commencer.

) : null} {articles.map((a) => { const st = articleStatusMeta(a.status); const title = a.title || "Sans titre"; return (

{title}

{st.label} · {a.date} · {a.kw || "—"}

{a.status === "published" ? ( <> {(a.visits ?? 0).toLocaleString("fr-FR")} visites ) : ( )}
); })}
); } // =================== RÉSULTATS =================== function Sparkline({ data, w = 720, h = 220 }) { const series = Array.isArray(data) ? data.map(Number).filter((v) => Number.isFinite(v)) : []; if (series.length === 0) return null; const max = Math.max(...series); const min = Math.min(...series); const span = max - min || 1; const pts = series.map((v, i) => { const x = series.length === 1 ? w / 2 : (i / (series.length - 1)) * w; const y = h - ((v - min) / span) * (h - 30) - 15; return [x, y]; }); const line = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" "); const area = `${line} L${w} ${h} L0 ${h} Z`; return ( {pts.map((p, i) => i === pts.length - 1 && )} ); } // =================== MOTS-CLÉS =================== const INTENT_MAP = { info:"Informatif", commercial:"Commercial", transac:"Transactionnel" }; const INTENT_TONE = { info:"intent-info", commercial:"intent-com", transac:"intent-tx" }; function diffBadge(d) { if (d <= 30) return { label:`Facile · ${d}`, cls:"diff-easy" }; if (d <= 55) return { label:`Moyen · ${d}`, cls:"diff-med" }; return { label:`Difficile · ${d}`, cls:"diff-hard" }; } function KeywordsScreen({ keywords, opportunities, onRefresh, busy }) { const [filter, setFilter] = useS("all"); const filtered = filter==="favorites" ? keywords.filter(k=>k.favorite) : filter==="new" ? keywords.filter(k=>!k.hasArticle) : keywords; return (
SEO GUY

J'ai repéré {opportunities.length} opportunités faciles à saisir

Peu de concurrence, bon volume — aucun article ne les cible encore. Un clic suffit.

{opportunities.map(k => (
{k.keyword} {k.volume.toLocaleString("fr")} rech./mois
))}

Tous les mots-clés

{[["all","Tous"],["favorites","⭐ Favoris"],["new","Sans article"]].map(([v,l]) => ( ))}
Mot-clé Volume Difficulté Intention Position
{filtered.map(k => { const db = diffBadge(k.difficulty); return (
{k.keyword} {k.volume.toLocaleString("fr")} {db.label} {INTENT_MAP[k.intent]} {k.position ? #{k.position} : Non classé} {k.hasArticle ? Article ✓ : }
); })}
); } // =================== CONCURRENTS =================== function posCell(v) { if (!v) return ; const cls = v<=3?"sg-pos-top":v<=10?"sg-pos-mid":"sg-pos-low"; return #{v}; } // =================== RÉSULTATS (avec onglet GSC) =================== function compactNum(n) { if (n == null || Number.isNaN(Number(n))) return "—"; const v = Number(n); if (v >= 1000) return `${(v / 1000).toFixed(1).replace(".0", "")}K`; return v.toLocaleString("fr-FR"); } function ResultsScreen({ gscQueries = [], performance }) { const perf = performance?.data || performance; const [tab, setTab] = useS("traffic"); const gscOn = !!perf?.gscConnected; const trafficData = mapTrafficSeries(perf); const top = perf?.topArticles ?? []; const queries = Array.isArray(gscQueries) ? gscQueries : []; const days = perf?.daysBack ?? 30; const weekCount = perf?.trafficSeries?.length ?? 0; const weeksLabel = weekCount === 0 ? (gscOn ? "Aucune donnée" : "GSC non connectée") : weekCount === 1 ? "1 semaine" : `${weekCount} semaines`; const gscStatus = gscOn ? "Search Console connectée" : "Search Console non connectée"; const emptyGsc = gscOn ? "Pas encore de données sur cette période." : "Connectez Google Search Console pour afficher le trafic."; return (
{tab === "traffic" && ( <>
= 0} /> 0 ? String(perf.averagePosition.current) : "—"} delta={gscOn ? formatPositionDelta(perf?.averagePosition?.deltaPct) : "—"} up={(perf?.averagePosition?.deltaPct ?? 0) <= 0} />

Trafic organique

{weeksLabel}
{trafficData.length > 0 ? :

{emptyGsc}

}

Articles les plus performants

{top.length === 0 ? (

Publiez des articles et connectez la GSC pour voir les performances.

) : (
{top.map((t,i) => (
{i+1}

{t.title}

{t.revenue ? `CA ${t.revenue} €` : ""}

#{t.position ?? t.pos ?? "—"}Google
{Number(t.visits || 0).toLocaleString("fr")}visites
))}
)}
)} {tab === "gsc" && ( <>
= 0} /> = 0} /> = 0} />

Requêtes de recherche

{days} derniers jours
{queries.length === 0 ? (

{emptyGsc}

) : ( <>
Requête Clics Impressions CTR Position moy.
{queries.map((q,i) => (
{q.query} {Number(q.clicks || 0).toLocaleString("fr")} {Number(q.impressions || 0).toLocaleString("fr")} {q.ctr} % {Number(q.position).toFixed(1)}
))}
)}
)}
); } function normalizePublicationSlots(slots) { return (slots || []) .map((s) => ({ weekday: Number(s.weekday), hour: Number(s.hour), minute: Number(s.minute ?? 0), kind: normalizeSlotKind(s.kind), personaIndex: s.personaIndex != null && Number.isFinite(Number(s.personaIndex)) && Number(s.personaIndex) >= 0 ? Number(s.personaIndex) : null, })) .filter((s) => s.weekday >= 1 && s.weekday <= 7 && s.hour >= 0 && s.hour <= 23) .sort((a, b) => a.weekday - b.weekday || a.hour - b.hour || a.minute - b.minute); } function rhythmSlotPersonaName(slot, personaOptions) { if (slot?.personaIndex == null) return null; return personaOptions?.[slot.personaIndex]?.name || null; } function slotAtDayHour(slots, weekday, hour) { return (slots || []).find((s) => s.weekday === weekday && s.hour === hour); } function slotKey(s) { return `${s.weekday}-${s.hour}-${s.minute ?? 0}`; } function findSlotIndexByKey(slots, slot, exceptIndex = -1) { const key = slotKey(slot); return (slots || []).findIndex((s, i) => i !== exceptIndex && slotKey(s) === key); } // =================== MODALE — CONFIGURATION CRÉNEAU =================== function RhythmSlotModal({ open, editIndex = null, initial, existingSlots = [], personaOptions = [], interviewsAllowed = false, disabled = false, busy = false, onClose, onSave, onDelete, }) { const dayLabels = EDITORIAL_WEEKDAYS || []; const slotKinds = PUBLICATION_SLOT_KINDS || []; const [weekday, setWeekday] = useS(2); const [hour, setHour] = useS(9); const [minute, setMinute] = useS(0); const [kind, setKind] = useS("article"); const [personaIndex, setPersonaIndex] = useS(null); const [error, setError] = useS(""); useE(() => { if (!open || !initial) return; setWeekday(initial.weekday ?? 2); setHour(initial.hour ?? 9); setMinute(initial.minute ?? 0); setKind(normalizeSlotKind(initial.kind)); setPersonaIndex( initial.personaIndex != null && Number.isFinite(Number(initial.personaIndex)) ? Number(initial.personaIndex) : personaOptions[0]?.index ?? null, ); setError(""); }, [open, initial, personaOptions]); if (!open || !initial) return null; const isEdit = editIndex != null; const hourOptions = [...new Set([...(RHYTHM_GRID_HOURS || []), hour])].sort((a, b) => a - b); const dayLabel = dayLabels.find((d) => d.v === weekday)?.label || "—"; function handleKindChange(nextKind) { setKind(nextKind); if (nextKind === "interview") { setPersonaIndex(null); } else if (personaOptions.length && personaIndex == null) { setPersonaIndex(personaOptions[0]?.index ?? null); } } function handleValidate() { const payload = { weekday: Number(weekday), hour: Number(hour), minute: Number(minute), kind: normalizeSlotKind(kind), personaIndex: normalizeSlotKind(kind) === "article" && personaOptions.length ? personaIndex : null, }; if (normalizeSlotKind(kind) === "article" && personaOptions.length && payload.personaIndex == null) { setError("Choisissez un persona pour ce créneau article."); return; } if (findSlotIndexByKey(existingSlots, payload, editIndex ?? -1) >= 0) { setError("Un créneau existe déjà à cette date et heure."); return; } setError(""); onSave?.(payload, editIndex); } return (
!busy && onClose?.()}>
e.stopPropagation()} role="dialog" aria-labelledby="sg-rhythm-slot-modal-title" >

{isEdit ? "Modifier le créneau" : "Nouveau créneau"}

Guy placera ici un contenu du plan éditorial (article ou interview) à l'horaire choisi.

Jour

{dayLabels.map((d) => ( ))}

Heure précise

{hourOptions.map((h) => ( ))}
{(EDITORIAL_MINUTE_OPTIONS || [0, 15, 30, 45]).map((m) => ( ))}

Publication : {dayLabel} à {String(hour).padStart(2, "0")}h {minute ? `:${String(minute).padStart(2, "0")}` : ""}

Format

{slotKinds.map((k) => ( ))}
{normalizeSlotKind(kind) === "interview" && !interviewsAllowed ? (

Les interviews ne sont pas incluses dans votre plan actuel.

) : null}
{normalizeSlotKind(kind) === "article" && personaOptions.length > 0 ? (

Persona cible

{personaOptions.map((p) => ( ))}
) : null} {error ?

{error}

: null}
{isEdit ? ( ) : ( )}
); } // =================== ÉDITEUR GRAPHIQUE — RYTHME DE PUBLICATION =================== function RhythmCollapsible({ id, title, hint, defaultOpen = false, badge, actions, children }) { const [open, setOpen] = useS(defaultOpen); return (
{actions ? (
e.stopPropagation()}> {actions}
) : null}
{!open && hint ?

{hint}

: null} {open ? (
{children}
) : null}
); } function PublicationRhythmEditor({ slots = [], maxSlots = 14, maxPerWeek = 4, rhythmOptions = [], rhythmValue, personaOptions = [], interviewsAllowed = false, disabled = false, busy = false, onChange, onApplyPreset, }) { const [localSlots, setLocalSlots] = useS(() => normalizePublicationSlots(slots)); const [slotModal, setSlotModal] = useS(null); useE(() => { setLocalSlots(normalizePublicationSlots(slots)); }, [slots]); async function commit(nextSlots) { const normalized = normalizePublicationSlots(nextSlots); setLocalSlots(normalized); if (onChange) await onChange(normalized); } function nextArticlePersonaIndex(currentSlots) { if (!personaOptions.length) return null; const articleCount = currentSlots.filter((s) => normalizeSlotKind(s.kind) === "article").length; return articleCount % personaOptions.length; } function openCreateModal(weekday, hour, kind = "article") { if (disabled || busy || localSlots.length >= maxSlots) return; if (kind === "interview" && !interviewsAllowed) return; setSlotModal({ editIndex: null, initial: { weekday, hour, minute: 0, kind, personaIndex: kind === "interview" ? null : nextArticlePersonaIndex(localSlots), }, }); } function openEditModal(index) { const slot = localSlots[index]; if (!slot) return; setSlotModal({ editIndex: index, initial: { ...slot }, }); } function closeSlotModal() { setSlotModal(null); } async function saveSlotFromModal(payload, editIndex) { let next; if (editIndex != null) { next = localSlots.map((s, i) => (i === editIndex ? payload : s)); } else { if (localSlots.length >= maxSlots) return; next = [...localSlots, payload]; } await commit(next); closeSlotModal(); } async function deleteSlotFromModal(editIndex) { await commit(localSlots.filter((_, i) => i !== editIndex)); closeSlotModal(); } function handleGridCellClick(weekday, hour) { if (disabled || busy) return; const index = localSlots.findIndex((s) => s.weekday === weekday && s.hour === hour); if (index >= 0) { openEditModal(index); return; } openCreateModal(weekday, hour); } const dayLabels = EDITORIAL_WEEKDAYS || []; function slotCellStyle(slot) { if (!slot) return undefined; const kind = normalizeSlotKind(slot.kind); if (kind === "interview") return undefined; if (slot.personaIndex == null) return undefined; const color = personaSlotColor(slot.personaIndex); return { borderColor: color, background: `${color}1f`, }; } function slotCellTitle(slot, dayLabel, hour) { if (!slot) return ""; const min = slot.minute ? `:${String(slot.minute).padStart(2, "0")}` : ""; const kind = normalizeSlotKind(slot.kind); const kindLabel = kind === "interview" ? "Interview" : "Article"; const persona = rhythmSlotPersonaName(slot, personaOptions); const personaPart = persona ? ` · ${persona}` : ""; return `${dayLabel} ${String(hour).padStart(2, "0")}h${min} · ${kindLabel}${personaPart} — modifier`; } function formatSlotTime(slot) { const min = slot.minute ? `:${String(slot.minute).padStart(2, "0")}` : ""; return `${String(slot.hour).padStart(2, "0")}h${min}`; } const articleCount = localSlots.filter((s) => normalizeSlotKind(s.kind) === "article").length; const interviewCount = localSlots.filter((s) => normalizeSlotKind(s.kind) === "interview").length; return (

Créneaux horaires vides : Guy y insère articles et interviews selon votre plan éditorial. Cliquez une case de la grille pour ouvrir la configuration (format, persona, heure précise).

Cliquez une case pour configurer un créneau · {localSlots.length}/{maxSlots} max {maxPerWeek ? ` · plan : ${maxPerWeek} art./sem.` : ""}

{rhythmOptions.length > 1 && (
{rhythmOptions.map((opt) => ( ))}
)} void saveSlotFromModal(payload, editIndex)} onDelete={(editIndex) => void deleteSlotFromModal(editIndex)} />
); } const FORBIDDEN_TERMS_MAX_UI = 200; const FORBIDDEN_TERM_MAX_UI = 180; function parseForbiddenTermsUi(raw) { if (!raw || !String(raw).trim()) return []; const text = String(raw).replace(/\r\n/g, "\n").trim(); const chunks = text.includes("\n") ? text.split(/\n+/) : text.split(/[;\n]+/); const out = []; const seen = new Set(); for (const chunk of chunks) { const trimmed = chunk.trim().replace(/^[-•*]\s*/, ""); if (!trimmed) continue; const pieces = !text.includes("\n") && trimmed.includes(",") ? trimmed.split(",") : [trimmed]; for (const piece of pieces) { const term = piece.trim().replace(/^[-•*]\s*/, "").replace(/\s+/g, " "); if (!term) continue; const clipped = term.slice(0, FORBIDDEN_TERM_MAX_UI); const key = clipped.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(clipped); if (out.length >= FORBIDDEN_TERMS_MAX_UI) return out; } } return out; } function hydrateForbiddenEntriesUi(cfg) { const raw = Array.isArray(cfg?.forbiddenEntries) ? cfg.forbiddenEntries : []; if (raw.length) { const seen = new Set(); const out = []; for (const entry of raw) { const term = String(entry?.term || "").trim(); if (!term) continue; const key = term.toLowerCase(); if (seen.has(key)) continue; seen.add(key); const origin = entry.origin === "pack" || entry.origin === "document" ? entry.origin : "manual"; out.push({ term, origin, sourceId: entry.sourceId || "", sourceLabel: entry.sourceLabel || "", }); } return out; } return parseForbiddenTermsUi(cfg?.forbiddenKeywords).map((term) => ({ term, origin: "manual", sourceId: "", sourceLabel: "", })); } function forbiddenChipTitle(entry) { if (!entry) return ""; if (entry.origin === "pack" && entry.sourceLabel) { return `${entry.term} — ${entry.sourceLabel}`; } if (entry.origin === "document") { return `${entry.term} — extrait d’un référentiel`; } return entry.term; } // =================== RÉGLAGES =================== function SettingsScreen({ publicationSchedule, editorialQuota, planCode = "", planLabel = "", personas = [], targetAudiences = [], configuration, publicationScheduleAllowed = false, live, shopify, onSave, onSaveSchedule, onSaveConfig, onResyncShopify, onSaveShopifyConnection, onTestShopifyConnection, onDisconnectShopify, wordpress, onSaveWordpressConnection, onTestWordpressConnection, onDisconnectWordpress, busy, }) { const [resyncMsg, setResyncMsg] = useS(""); const [resyncErr, setResyncErr] = useS(""); const [resyncBusy, setResyncBusy] = useS(false); const [saveMsg, setSaveMsg] = useS(""); const [saveErr, setSaveErr] = useS(""); const [saving, setSaving] = useS(false); const [liabilityOpen, setLiabilityOpen] = useS(false); const [rhythmOpen, setRhythmOpen] = useS(false); const maxPerWeek = editorialQuota?.articlesPerWeek ?? 1; const interviewsAllowed = (editorialQuota?.interviewsPerPeriod ?? 0) > 0; const rhythmOptions = buildRhythmOptions(maxPerWeek); const personaOptions = buildPersonaOptionsFromTargets(targetAudiences, personas); const sched = publicationSchedule || {}; const fullAuto = !!sched.enabled && !!sched.publishWithoutReview; const [pilotMode, setPilotMode] = useS(fullAuto ? "auto" : "semi"); const [rhythmValue, setRhythmValue] = useS(() => inferRhythmFromSchedule(sched, maxPerWeek)); const [toneLabel, setToneLabel] = useS(() => inferDefaultEditorialTone(personas, targetAudiences)); const [draftSlots, setDraftSlots] = useS(() => normalizePublicationSlots( sched.slots?.length ? sched.slots : suggestSlotsForRhythm(inferRhythmFromSchedule(sched, maxPerWeek), planCode, maxPerWeek), )); useE(() => { setPilotMode(sched.enabled && sched.publishWithoutReview ? "auto" : "semi"); const inferred = inferRhythmFromSchedule(sched, maxPerWeek); setRhythmValue(inferred); setToneLabel(inferDefaultEditorialTone(personas, targetAudiences)); setDraftSlots( normalizePublicationSlots( sched.slots?.length ? sched.slots : suggestSlotsForRhythm(inferred, planCode, maxPerWeek), ), ); }, [publicationSchedule, maxPerWeek, personas, targetAudiences, planCode]); const shopifyConnected = shopify?.publishingMode === "shopify" || !!shopify?.canPublishOnShopify || !!shopify?.connected || !!live?.session?.shopifyConnection?.connected; const hasAccessToken = !!shopify?.hasAccessToken || !!live?.session?.shopifyConnection?.hasAccessToken; const pendingSync = shopify?.pendingSyncCount ?? 0; const withContent = shopify?.withContentCount ?? 0; const shopLabel = shopify?.shop || live?.site?.shop || "Boutique Shopify"; const siteSettings = live?.settings || {}; const isWebsiteSite = live?.site?.platform === "WEBSITE" || String(shopify?.shop || live?.site?.shop || "").startsWith("website:"); const wordpressConnected = !!wordpress?.connected || !!live?.wordpressConnection?.connected; const cmsConnected = wordpressConnected || shopifyConnected; const wpSuggestedUrl = (() => { const existing = wordpress?.baseUrl || live?.wordpressConnection?.baseUrl; if (existing) return existing; const shop = shopify?.shop || live?.site?.shop || ""; const host = String(shop).replace(/^website:/i, "").replace(/^https?:\/\//i, "").split("/")[0]; return host ? `https://${host}` : ""; })(); const canEditSchedule = publicationScheduleAllowed; const [defaultBlogGid, setDefaultBlogGid] = useS(() => siteSettings.defaultBlogGid || ""); const [defaultAuthor, setDefaultAuthor] = useS(() => siteSettings.defaultAuthor || "Guy SEO"); const [shopifyBlogs, setShopifyBlogs] = useS([]); const [blogsLoading, setBlogsLoading] = useS(false); const [cmsOpen, setCmsOpen] = useS(false); const [localeOpen, setLocaleOpen] = useS(false); const [forbidOpen, setForbidOpen] = useS(() => parseForbiddenTermsUi(configuration?.forbiddenKeywords).length > 0 || !!configuration?.compliancePackId, ); const [connOpen, setConnOpen] = useS(false); const [forbiddenTerms, setForbiddenTerms] = useS(() => parseForbiddenTermsUi(configuration?.forbiddenKeywords), ); const [forbiddenEntries, setForbiddenEntries] = useS(() => hydrateForbiddenEntriesUi(configuration), ); const [compliancePacks, setCompliancePacks] = useS([]); const [packId, setPackId] = useS(() => configuration?.compliancePackId || ""); const [regulationIds, setRegulationIds] = useS(() => Array.isArray(configuration?.complianceRegulations) ? [...configuration.complianceRegulations] : [], ); const [certificationIds, setCertificationIds] = useS(() => Array.isArray(configuration?.complianceCertifications) ? [...configuration.complianceCertifications] : [], ); const [forbidDraft, setForbidDraft] = useS(""); const [forbidPaste, setForbidPaste] = useS(""); const [regulatoryDraft, setRegulatoryDraft] = useS(() => configuration?.regulatoryReferences || ""); const [forbidMsg, setForbidMsg] = useS(""); const [forbidErr, setForbidErr] = useS(""); const [forbidBusy, setForbidBusy] = useS(false); const [forbidSaving, setForbidSaving] = useS(false); const [tokenShop, setTokenShop] = useS(() => shopify?.shop || live?.session?.site?.shop || ""); const [tokenValue, setTokenValue] = useS(""); const [tokenBusy, setTokenBusy] = useS(false); const [tokenMsg, setTokenMsg] = useS(""); const [tokenErr, setTokenErr] = useS(""); const [wpOpen, setWpOpen] = useS(isWebsiteSite); const [wpBaseUrl, setWpBaseUrl] = useS(wpSuggestedUrl); const [wpUsername, setWpUsername] = useS(wordpress?.username || live?.wordpressConnection?.username || ""); const [wpPassword, setWpPassword] = useS(""); const [wpCategoryId, setWpCategoryId] = useS(wordpress?.categoryId || live?.wordpressConnection?.categoryId || ""); const [wpCategories, setWpCategories] = useS([]); const [wpBusy, setWpBusy] = useS(false); const [wpMsg, setWpMsg] = useS(""); const [wpErr, setWpErr] = useS(""); const [preferredLanguage, setPreferredLanguage] = useS(() => siteSettings.preferredLanguage || "fr"); const [targetCountries, setTargetCountries] = useS(() => Array.isArray(siteSettings.targetCountries) ? [...siteSettings.targetCountries] : [], ); useE(() => { setDefaultBlogGid(siteSettings.defaultBlogGid || ""); setDefaultAuthor(siteSettings.defaultAuthor || "Guy SEO"); setPreferredLanguage(siteSettings.preferredLanguage || "fr"); setTargetCountries(Array.isArray(siteSettings.targetCountries) ? [...siteSettings.targetCountries] : []); }, [ siteSettings.defaultBlogGid, siteSettings.defaultAuthor, siteSettings.preferredLanguage, siteSettings.targetCountries, ]); useE(() => { const entries = hydrateForbiddenEntriesUi(configuration); setForbiddenEntries(entries); setForbiddenTerms(entries.map((e) => e.term)); }, [configuration?.forbiddenKeywords, configuration?.forbiddenEntries]); useE(() => { setPackId(configuration?.compliancePackId || ""); setRegulationIds( Array.isArray(configuration?.complianceRegulations) ? [...configuration.complianceRegulations] : [], ); setCertificationIds( Array.isArray(configuration?.complianceCertifications) ? [...configuration.complianceCertifications] : [], ); }, [ configuration?.compliancePackId, configuration?.complianceRegulations, configuration?.complianceCertifications, ]); useE(() => { if (typeof GuyAPI?.listCompliancePacks !== "function") return; let cancelled = false; GuyAPI.listCompliancePacks() .then((res) => { if (!cancelled) setCompliancePacks(Array.isArray(res?.packs) ? res.packs : []); }) .catch(() => { if (!cancelled) setCompliancePacks([]); }); return () => { cancelled = true; }; }, []); useE(() => { setRegulatoryDraft(configuration?.regulatoryReferences || ""); }, [configuration?.regulatoryReferences]); useE(() => { if (!shopifyConnected || typeof GuyAPI?.shopifyBlogs !== "function") return; let cancelled = false; setBlogsLoading(true); GuyAPI.shopifyBlogs() .then((res) => { if (!cancelled) setShopifyBlogs(res?.blogs || []); }) .catch(() => { if (!cancelled) setShopifyBlogs([]); }) .finally(() => { if (!cancelled) setBlogsLoading(false); }); return () => { cancelled = true; }; }, [shopifyConnected]); function selectedBlogLabel() { if (!defaultBlogGid) return "Brouillon local"; const blog = shopifyBlogs.find((b) => b.id === defaultBlogGid); return blog ? blog.title : "Blog configuré"; } async function persistSiteSettings(patch = {}, successMsg = "Réglages enregistrés.") { if (!onSave) return; if (patch.defaultBlogGid !== undefined) setDefaultBlogGid(patch.defaultBlogGid || ""); if (patch.defaultAuthor !== undefined) setDefaultAuthor(patch.defaultAuthor || "Guy SEO"); if (patch.preferredLanguage !== undefined) setPreferredLanguage(patch.preferredLanguage || "fr"); if (patch.targetCountries !== undefined) { setTargetCountries(Array.isArray(patch.targetCountries) ? [...patch.targetCountries] : []); } setSaving(true); setSaveErr(""); setSaveMsg(""); try { const payload = {}; if (patch.defaultBlogGid !== undefined) payload.defaultBlogGid = patch.defaultBlogGid || null; if (patch.defaultAuthor !== undefined) { payload.defaultAuthor = (patch.defaultAuthor || "Guy SEO").trim() || "Guy SEO"; } if (patch.preferredLanguage !== undefined) payload.preferredLanguage = patch.preferredLanguage || "fr"; if (patch.targetCountries !== undefined) payload.targetCountries = patch.targetCountries; await onSave(payload); setSaveMsg(successMsg); } catch (e) { setSaveErr(e.message || "Enregistrement impossible."); } finally { setSaving(false); } } async function persistCmsSettings(patch = {}) { await persistSiteSettings(patch, "Publication CMS enregistrée."); } function toggleTargetCountry(code) { const set = new Set(targetCountries); if (set.has(code)) set.delete(code); else set.add(code); void persistSiteSettings({ targetCountries: [...set] }, "Ciblage enregistré."); } async function persistForbiddenTerms(nextTerms, successMsg) { if (!onSaveConfig) return; const cleaned = parseForbiddenTermsUi((nextTerms || []).join("\n")); const byKey = new Map(forbiddenEntries.map((e) => [e.term.toLowerCase(), e])); const nextEntries = cleaned.map((term) => byKey.get(term.toLowerCase()) || { term, origin: "manual", sourceId: "", sourceLabel: "", }); setForbiddenEntries(nextEntries); setForbiddenTerms(cleaned); setForbidSaving(true); setForbidErr(""); setForbidMsg(""); try { await onSaveConfig({ forbiddenKeywords: cleaned.join("\n") }); setForbidMsg(successMsg || "Interdits enregistrés."); } catch (e) { setForbidErr(e.message || "Enregistrement impossible."); } finally { setForbidSaving(false); } } function addForbiddenDraft() { const extra = parseForbiddenTermsUi(forbidDraft); if (!extra.length) return; setForbidDraft(""); void persistForbiddenTerms( [...forbiddenTerms, ...extra], extra.length > 1 ? `${extra.length} expressions ajoutées.` : "Expression ajoutée.", ); } function addForbiddenPaste() { const extra = parseForbiddenTermsUi(forbidPaste); if (!extra.length) return; const next = parseForbiddenTermsUi([...forbiddenTerms, ...extra].join("\n")); const added = next.length - forbiddenTerms.length; setForbidPaste(""); void persistForbiddenTerms( next, added > 0 ? `${added} expression(s) ajoutée(s).` : "Aucun nouveau terme (déjà présents).", ); } function removeForbiddenTerm(term) { void persistForbiddenTerms( forbiddenTerms.filter((t) => t.toLowerCase() !== term.toLowerCase()), "Expression retirée.", ); } function clearAllForbiddenTerms() { if (!forbiddenTerms.length) return; if (!window.confirm("Effacer tous les mots-clés et expressions interdits ?")) return; void persistForbiddenTerms([], "Tous les interdits ont été effacés."); } async function persistRegulatoryOnly() { if (!onSaveConfig) return; setForbidSaving(true); setForbidErr(""); setForbidMsg(""); try { await onSaveConfig({ regulatoryReferences: regulatoryDraft }); setForbidMsg("Référentiel enregistré."); } catch (e) { setForbidErr(e.message || "Enregistrement impossible."); throw e; } finally { setForbidSaving(false); } } const isRegulatoryDirty = (regulatoryDraft || "").trim() !== (configuration?.regulatoryReferences || "").trim(); useUnsavedChangesGuard({ isDirty: isRegulatoryDirty, onSave: persistRegulatoryOnly, onDiscard: () => setRegulatoryDraft(configuration?.regulatoryReferences || ""), }); async function analyzeRegulatory() { if (typeof GuyAPI?.analyzeForbiddenFromRegulatory !== "function") return; setForbidBusy(true); setForbidErr(""); setForbidMsg(""); try { const res = await GuyAPI.analyzeForbiddenFromRegulatory({ regulatoryText: regulatoryDraft, }); const terms = Array.isArray(res.terms) ? res.terms : parseForbiddenTermsUi(res.configuration?.forbiddenKeywords); const entries = hydrateForbiddenEntriesUi(res.configuration); setForbiddenEntries(entries.length ? entries : terms.map((term) => ({ term, origin: "document", sourceId: "", sourceLabel: "Référentiel collé", }))); setForbiddenTerms(terms); if (res.configuration?.regulatoryReferences != null) { setRegulatoryDraft(res.configuration.regulatoryReferences); } const added = Array.isArray(res.added) ? res.added.length : 0; setForbidMsg( added ? `${added} expression(s) extraite(s) et ajoutée(s).` : "Analyse terminée : aucun nouveau terme.", ); } catch (e) { setForbidErr(e.message || "Analyse impossible."); } finally { setForbidBusy(false); } } const selectedPack = compliancePacks.find((p) => p.id === packId) || null; async function persistPackProfile(nextPackId, nextRegs, nextCerts) { if (!onSaveConfig) return; setForbidSaving(true); setForbidErr(""); try { await onSaveConfig({ compliancePackId: nextPackId || null, complianceRegulations: nextRegs, complianceCertifications: nextCerts, }); } catch (e) { setForbidErr(e.message || "Enregistrement du pack impossible."); } finally { setForbidSaving(false); } } function onPackSelect(nextId) { const nextPack = compliancePacks.find((p) => p.id === nextId); const nextRegs = nextPack ? (nextPack.regulations || []).filter((s) => s.defaultOn).map((s) => s.id) : []; const nextCerts = []; setPackId(nextId); setRegulationIds(nextRegs); setCertificationIds(nextCerts); void persistPackProfile(nextId, nextRegs, nextCerts); } function toggleRegulation(id) { const set = new Set(regulationIds); if (set.has(id)) set.delete(id); else set.add(id); const next = [...set]; setRegulationIds(next); void persistPackProfile(packId, next, certificationIds); } function toggleCertification(id) { const set = new Set(certificationIds); if (set.has(id)) set.delete(id); else set.add(id); const next = [...set]; setCertificationIds(next); void persistPackProfile(packId, regulationIds, next); } async function generateFromPack() { if (typeof GuyAPI?.generateForbiddenFromPack !== "function" || !packId) return; setForbidBusy(true); setForbidErr(""); setForbidMsg(""); try { const res = await GuyAPI.generateForbiddenFromPack({ packId, regulations: regulationIds, certifications: certificationIds, }); const entries = hydrateForbiddenEntriesUi(res.configuration); const terms = entries.length ? entries.map((e) => e.term) : Array.isArray(res.terms) ? res.terms : parseForbiddenTermsUi(res.configuration?.forbiddenKeywords); setForbiddenEntries(entries.length ? entries : terms.map((term) => ({ term, origin: "pack", sourceId: "", sourceLabel: "", }))); setForbiddenTerms(terms); const added = Array.isArray(res.added) ? res.added.length : 0; const packCount = typeof res.packCount === "number" ? res.packCount : added; setForbidMsg( `${packCount} interdit${packCount > 1 ? "s" : ""} du pack. Les termes saisis à la main sont conservés${added ? ` · ${added} nouveau(x)` : ""}.`, ); } catch (e) { setForbidErr(e.message || "Génération du pack impossible."); } finally { setForbidBusy(false); } } function buildSchedulePayload(overrides) { const slots = overrides.slots ?? draftSlots; return { enabled: sched.enabled ?? false, timezone: sched.timezone || "Europe/Paris", cadence: sched.cadence || "weekly", biweeklyOnEvenIsoWeeks: sched.biweeklyOnEvenIsoWeeks !== false, slots: slots.length ? slots : suggestSlotsForRhythm(rhythmValue, planCode, maxPerWeek), requireReviewBeforePublish: true, publishWithoutReview: false, liabilityAcceptedAt: null, lastProcessedSlotKey: sched.lastProcessedSlotKey ?? null, ...overrides, }; } async function persistWithSlots(slots, extraOverrides = {}) { const normalized = normalizePublicationSlots(slots); setDraftSlots(normalized); setRhythmValue(inferRhythmFromSchedule({ slots: normalized }, maxPerWeek)); await persistSchedule(buildSchedulePayload({ slots: normalized, ...extraOverrides })); } async function persistSchedule(payload) { if (!onSaveSchedule) return; setSaving(true); setSaveErr(""); setSaveMsg(""); try { await onSaveSchedule(payload); setSaveMsg("Réglages enregistrés."); } catch (e) { setSaveErr(e.message || "Enregistrement impossible."); } finally { setSaving(false); } } async function persistTone(nextTone) { if (!onSaveConfig || !targetAudiences?.length) return; setSaving(true); setSaveErr(""); try { await onSaveConfig({ targetAudiences: targetAudiences.map((t) => ({ ...t, tone: nextTone })), }); setSaveMsg("Ton éditorial enregistré."); } catch (e) { setSaveErr(e.message || "Enregistrement du ton impossible."); } finally { setSaving(false); } } async function applySemiAuto() { setPilotMode("semi"); await persistWithSlots(draftSlots, { enabled: true, publishWithoutReview: false, requireReviewBeforePublish: true, liabilityAcceptedAt: null, }); } async function applyFullAuto() { setLiabilityOpen(false); setPilotMode("auto"); await persistWithSlots(draftSlots, { enabled: true, publishWithoutReview: true, requireReviewBeforePublish: false, liabilityAcceptedAt: new Date().toISOString(), }); } async function applyRhythmPreset(value) { setRhythmValue(value); const slots = enrichPublicationSlots( suggestSlotsForRhythm(value, planCode, maxPerWeek), personaOptions.length, ); if (!canEditSchedule) return; await persistWithSlots(slots, { enabled: sched.enabled || pilotMode === "semi" || pilotMode === "auto", publishWithoutReview: pilotMode === "auto", requireReviewBeforePublish: pilotMode !== "auto", liabilityAcceptedAt: pilotMode === "auto" ? sched.liabilityAcceptedAt : null, }); } async function applySlotsFromEditor(slots) { if (!canEditSchedule) return; await persistWithSlots(slots, { enabled: sched.enabled || pilotMode === "semi" || pilotMode === "auto", publishWithoutReview: pilotMode === "auto", requireReviewBeforePublish: pilotMode !== "auto", liabilityAcceptedAt: pilotMode === "auto" ? sched.liabilityAcceptedAt : null, }); } async function applyTone(label) { setToneLabel(label); await persistTone(label); } async function runShopifyResync() { if (!onResyncShopify || resyncBusy) return; setResyncMsg(""); setResyncErr(""); setResyncBusy(true); try { const res = await onResyncShopify(); setResyncMsg(res?.message || "Synchronisation terminée."); } catch (e) { setResyncErr(e.message || "Synchronisation impossible."); } finally { setResyncBusy(false); } } return (
Réglages éditoriaux} sub="Pilotage, rythme et ton — alignés sur votre plan et votre calendrier éditorial." />
{planLabel ? {planLabel} : null}

Pilotage

Choisissez comment SEO Guy gère la rédaction et la publication de vos contenus.

{pilotMode === "auto" && (

Assistant Guy rédige, publie et suit sans votre vérification. Vous restez néanmoins responsable de la publication au sens légal. Nous vous invitons à contrôler régulièrement les contenus créés.

)} {!canEditSchedule && (
Le pilotage automatique nécessite un plan Starter, Pro ou Business.
)}
{!rhythmOpen && draftSlots.length > 0 ? (
{draftSlots.map((slot, index) => { const kind = normalizeSlotKind(slot.kind); const personaIdx = slot.personaIndex; return ( {kind === "interview" ? ( ITW ) : ( Art )} {formatRhythmSlotTime(slot)} {rhythmSlotPersonaName(slot, personaOptions) ? ` · ${rhythmSlotPersonaName(slot, personaOptions)}` : ""} ); })}
) : null} {!rhythmOpen && !draftSlots.length ? (

Aucun créneau — ouvrez la configuration pour définir votre rythme.

) : null}
{rhythmOpen ? ( ) : null}

Ton éditorial

La voix de vos articles — alignée sur vos personas et le funnel segments. {!targetAudiences?.length ? " Complétez vos segments pour ancrer le ton en base." : ""}

{PERSONA_TONES.map((t) => ( ))}
{saveMsg ?

{saveMsg}

: null} {saveErr ?

{saveErr}

: null} {saving ?

Enregistrement…

: null}
{defaultBlogGid ? "Shopify" : "Brouillon local"}

Publication CMS

Blog et auteur affichés sur vos articles publiés dans Shopify.

{cmsOpen ? (

Blog par défaut

Les nouveaux articles sont créés dans ce blog Shopify (statut brouillon).

{!shopifyConnected ? (

Boutique non connectée — publication en mode brouillon local.

) : blogsLoading ? (

Chargement des blogs Shopify…

) : shopifyBlogs.length > 0 ? ( ) : (

Aucun blog détecté dans cette boutique.

)}

Auteur par défaut

Nom affiché sur les articles publiés.

setDefaultAuthor(e.target.value)} onBlur={() => void persistCmsSettings({ defaultAuthor })} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void persistCmsSettings({ defaultAuthor }); } }} />
{saveMsg ?

{saveMsg}

: null} {saveErr ?

{saveErr}

: null} {saving ?

Enregistrement…

: null}
) : null}
{labelForLanguage(preferredLanguage)}

Langues & ciblage

Langue de rédaction des articles et marchés SEO visés par Guy.

{localeOpen ? (

Langue principale

Langue dans laquelle Guy rédige vos articles.

Pays ciblés

Marchés prioritaires pour le SEO et les recommandations éditoriales.

{(COUNTRY_OPTIONS || []).map((c) => ( ))}

{targetCountries.length ? `${targetCountries.length} pays sélectionné(s) · ${formatTargetCountriesSummary(targetCountries)}` : "Sélectionnez au moins un pays pour ancrer le ciblage SEO."}

{saveMsg ?

{saveMsg}

: null} {saveErr ?

{saveErr}

: null} {saving ?

Enregistrement…

: null}
) : null}
{forbiddenTerms.length ? `${forbiddenTerms.length} interdit${forbiddenTerms.length > 1 ? "s" : ""}` : "Aucun"}

Interdits rédactionnels

Pack métier pour pré-remplir les interdits, ou saisie manuelle. Guy est un garde-fou éditorial, pas un juriste.

{forbidOpen ? (

Pack métier

Sans pack, vous configurez les interdits à la main. Un pack génère une liste sourcée : vos termes saisis manuellement sont conservés.

{selectedPack ? ( <>

{selectedPack.description}

Cadres réglementaires

{(selectedPack.regulations || []).map((item) => ( ))}

Certifications (optionnel)

Cochez seulement si la marque est réellement certifiée — Guy ajoute alors des interdits extra (surpromesses « 100 % naturel », etc.).

{(selectedPack.certifications || []).map((item) => ( ))}

Garde-fou éditorial — ne remplace pas un conseil juridique.

) : null}
{forbiddenTerms.length ? (
{(forbiddenEntries.length ? forbiddenEntries : forbiddenTerms.map((term) => ({ term, origin: "manual" }))).map((entry) => ( {entry.term} ))}
) : (

Aucun mot-clé ni expression interdit pour le moment.

)}
setForbidDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addForbiddenDraft(); } }} />

Coller une liste

Un terme par ligne. Virgules acceptées si tout est sur une seule ligne.