// SEO GUY Web V2 — écrans étendus (couverture inventaire complet)
const { useState: useS, useEffect: useE, useRef: useR } = React;
function Sparkline({ data, w = 720, h = 220 }) {
const series = Array.isArray(data) ? data.filter((v) => Number.isFinite(v)) : [];
if (series.length === 0) return null;
const max = Math.max(...series, 1);
const pts = series.map((v, i) => {
const x = series.length === 1 ? w / 2 : (i / (series.length - 1)) * w;
const y = h - (v / max) * (h - 24) - 12;
return `${x},${y}`;
}).join(" ");
return (
);
}
function PerformanceScreen({ publishedThisMonth, performance, kpis, traffic }) {
const [days, setDays] = useS("30");
const [perfLoading, setPerfLoading] = useS(false);
const [perfOverride, setPerfOverride] = useS(null);
useE(() => {
let cancelled = false;
setPerfLoading(true);
GuyAPI.performanceOverall(days)
.then((body) => {
if (!cancelled && body?.performance) setPerfOverride(body.performance);
})
.catch(() => {
if (!cancelled) setPerfOverride(null);
})
.finally(() => {
if (!cancelled) setPerfLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
const perf = perfOverride || performance?.data || performance;
const kpiData = kpis && !perfOverride ? kpis : mapKpisFromPerformance(perf);
const trafficData = traffic && !perfOverride ? traffic : mapTrafficSeries(perf);
const top = perf?.topArticles ?? [];
const baseline = perf?.baseline;
const weekCount = perf?.trafficSeries?.length ?? 0;
const weeksLabel =
weekCount === 0
? (perf?.gscConnected ? "Aucune donnée" : "GSC non connectée")
: weekCount === 1
? "1 semaine"
: `${weekCount} semaines`;
return (
{["7", "30", "90"].map((d) => (
setDays(d)}>{d} jours
))}
{perfLoading ?
Actualisation… : null}
Évolution du trafic SEO
{weeksLabel}
{trafficData.length > 0 ? (
) : (
{perf?.gscConnected
? "Pas encore de données sur cette période."
: "Connectez Google Search Console pour afficher le trafic."}
)}
Impact AVANT / APRÈS SEO GUY
{publishedThisMonth} articles validés ce mois-ci
Avant install
{baseline?.visitsBefore != null
? `${baseline.visitsBefore.toLocaleString("fr-FR")}`
: "—"}
visites/mois
{baseline?.visitsBefore != null
? "Baseline figée à la connexion GSC"
: "Baseline en cours de collecte"}
Aujourd'hui
{baseline?.visitsAfter ?? "—"} visites
Valeur trafic {perf?.trafficValueEur ? `${perf.trafficValueEur} €` : "estimée"}
Top publications performantes
{top.length === 0 ? (
Publiez des articles et connectez la GSC pour voir les performances.
) : (
{top.map((a, i) => (
{i + 1}
{a.title}
{a.revenue ? `CA ${a.revenue} €` : ""}
#{a.position ?? a.pos ?? "—"} position
{a.visits} visites
))}
)}
);
}
function AssistantHubCard({ icon, title, pill, description, bullets, ctaLabel, ctaPrimary = true, onClick }) {
return (
{pill ? {pill.label} : null}
{title}
{description}
{bullets.map((b) => (
{b}
))}
{ctaLabel}
);
}
function AssistantsHubScreen({
onOpen,
onStart,
segmentsCount = 0,
articlesCount = 0,
onboardingCompleted = false,
interviewCredits = 0,
editorialPlanAllowed = false,
publicationScheduleAllowed = false,
planCode = "",
}) {
const stats = [
{
label: "Segments éditoriaux",
value: String(segmentsCount),
hint: "Audiences et angles de contenu",
icon: "users",
},
{
label: "Articles dans Guy",
value: String(articlesCount),
hint: "Titres, brouillons et publiés",
icon: "docs",
},
{
label: "Site configuré",
value: onboardingCompleted ? "Oui" : "—",
hint: onboardingCompleted ? "Assistant démarrage terminé" : "À lancer en premier",
icon: "check",
},
];
return (
Assistant de démarrage >}
sub="Lancez le parcours guidé pour configurer votre site, vos cibles et votre premier article SEO."
/>
{stats.map((s) => (
{s.value}
{s.label}
{s.hint}
))}
Le grand tour : décrivez votre activité, choisissez vos pages ou
produits prioritaires, vos cibles et vos mots-clés. Guy crée votre premier{" "}
segment éditorial et un article modèle.
>
}
bullets={[
"Analyse du site + suggestions IA",
"Persona, ton, précautions de rédaction",
"Génération + relecture du 1er article",
]}
ctaLabel={onboardingCompleted ? "Relancer" : "Commencer"}
onClick={onStart}
/>
Astuce : vous pouvez relancer l'assistant à tout moment depuis cette page.
);
}
function SegmentPersonaCard({ persona, index, onEdit }) {
const enriched =
(persona.sellingPoints || []).length >= 1 && !!(persona.differentiation || "").trim();
const bullets = (persona.sellingPoints || []).filter(Boolean).slice(0, 3);
const fallbackBullets = bullets.length
? bullets
: (persona.interests || []).filter(Boolean).slice(0, 3).length
? persona.interests.slice(0, 3)
: [
persona.tone ? `Ton : ${persona.tone}` : "Ton éditorial à définir",
(persona.preferredProducts || []).length
? `${persona.preferredProducts.length} produit(s) associé(s)`
: "Produits à associer au segment",
"Parcours guidé en 4 étapes + synthèse IA",
];
return (
{persona.avatarUrl ? (
) : (
)}
{enriched ? (persona.tone || "Enrichi") : "À compléter"}
{persona.name}
{persona.desc}
{fallbackBullets.map((b) => (
{b}
))}
onEdit?.(index, persona)}
>
{enriched ? "Modifier le segment" : "Enrichir le segment"}
);
}
function SegmentAddCard({ atLimit, slotsLeft, onAdd }) {
return (
{atLimit ? "Limite atteinte" : `${slotsLeft} slot${slotsLeft > 1 ? "s" : ""} restant${slotsLeft > 1 ? "s" : ""}`}
Créer un segment de plus
Ajoutez une nouvelle cible (autre persona, autre univers produit)
à votre stratégie. Idéal pour adresser plusieurs audiences sans diluer le ton.
Reprend votre charte éditoriale
Ajoute une cible + produits dédiés
Arguments vendeurs + différenciation
!atLimit && onAdd?.()}
>
{atLimit ? "Passez à un plan supérieur" : "Ajouter un segment"}
);
}
function SegFunnelStepIntro({ icon, title, sub }) {
return (
);
}
function AssistantSegmentScreen({
onBack,
personas,
targets = [],
segmentsLimit = 3,
onEditSegment,
onAddSegment,
}) {
const list = personas || PERSONAS;
const count = targets?.length ?? list.length;
const atLimit = count >= segmentsLimit;
const enrichedCount = list.filter(
(p) => (p.sellingPoints || []).length >= 1 && (p.differentiation || "").trim(),
).length;
const slotsLeft = Math.max(0, segmentsLimit - count);
const stats = [
{
label: "Segments éditoriaux",
value: String(count),
hint: "Audiences et angles de contenu",
icon: "users",
},
{
label: "Segments enrichis",
value: String(enrichedCount),
hint: "Arguments + différenciation renseignés",
icon: "star",
},
{
label: "Limite du plan",
value: `${count}/${segmentsLimit}`,
hint: atLimit ? "Passez à un plan supérieur" : `${slotsLeft} slot${slotsLeft > 1 ? "s" : ""} disponible${slotsLeft > 1 ? "s" : ""}`,
icon: "target",
},
];
return (
Segments & personas >}
sub="Enrichissez chaque cible en 4 étapes : profil, produits, arguments et différenciation — Guy synthétise une fiche persona prête pour vos articles."
/>
{stats.map((s) => (
{s.value}
{s.label}
{s.hint}
))}
{list.map((p, i) => (
))}
← Retour aux assistants
Astuce : les arguments vendeurs et la différenciation de chaque segment alimentent automatiquement vos pré-prompts de rédaction.
);
}
const SEG_FUNNEL_STEP_META = [
{ icon: "user", title: "Client idéal", sub: "Profil, contexte de vie et motivations d'achat pour ce segment." },
{ icon: "card", title: "Produits préférés", sub: "Sélectionnez dans votre catalogue ou décrivez des familles produits." },
{ icon: "star", title: "Arguments vendeurs", sub: "Pourquoi ce segment devrait acheter — réutilisés dans vos pré-prompts articles." },
{ icon: "trending", title: "Vs concurrent", sub: "Votre différenciation concrète pour ce segment (service, expertise, promesse…)." },
{ icon: "spark", title: "Synthèse IA", sub: "Guy génère une fiche persona enrichie — relisez et ajustez avant enregistrement." },
];
const SEG_FUNNEL_STEPS = SEG_FUNNEL_STEP_META.map((m) => m.title);
const SEG_ANALYSIS_MSGS = [
"Guy croise profil et catalogue…",
"Affinage des arguments vendeurs…",
"Rédaction de la fiche persona…",
];
function normalizeSegmentDraft(d) {
return {
name: (d.name || "").trim(),
profile: (d.profile || "").trim(),
motivations: (d.motivations || "").trim(),
selectedProducts: [...(d.selectedProducts || [])].map((s) => s.trim()).filter(Boolean).sort(),
productFamilies: (d.productFamilies || "").trim(),
sellingPoints: (d.sellingPoints || ["", "", ""]).map((s) => (s || "").trim()),
differentiation: (d.differentiation || "").trim(),
};
}
function segmentDraftHasChanges(current, initial) {
return JSON.stringify(normalizeSegmentDraft(current)) !== JSON.stringify(normalizeSegmentDraft(initial));
}
function segmentDraftHasMinimalContent(d) {
const n = normalizeSegmentDraft(d);
return !!(
n.name ||
n.profile ||
n.motivations ||
n.selectedProducts.length ||
n.productFamilies ||
n.sellingPoints.some((s) => s.length > 0) ||
n.differentiation
);
}
function buildTargetFromDraft(draft, existingTarget, existingPersona) {
const parsed = existingTarget?.audience
? cfgParseAudience(existingTarget.audience)
: { name: "", description: "", interests: [] };
const name = draft.name.trim() || parsed.name || existingPersona?.name || "";
const description = draft.profile.trim() || parsed.description || existingPersona?.desc || "";
const interests = (existingPersona?.interests || parsed.interests || []).filter(Boolean);
const tone = existingTarget?.tone || existingPersona?.tone || "";
const sellingPoints = draft.sellingPoints.map((s) => s.trim()).filter(Boolean).slice(0, 3);
const differentiation = draft.differentiation.trim() || existingTarget?.differentiation || "";
const preferredProducts = draft.selectedProducts.length
? draft.selectedProducts
: (existingTarget?.preferredProducts || existingPersona?.preferredProducts || []);
const productFamilies = draft.productFamilies.trim() || existingTarget?.productFamilies || "";
let audience = description;
if (name && description) audience = `${name} — ${description}`;
else if (name) audience = name;
else if (existingTarget?.audience) audience = existingTarget.audience.split(" · Recherche :")[0].trim();
if (interests.length && audience) {
audience = `${audience}${CFG_INTEREST_MARKER}${interests.join(", ")}`;
}
const target = {
audience: audience || existingTarget?.audience || "",
tone,
avatarUrl: existingTarget?.avatarUrl ?? null,
};
const sp = sellingPoints.length ? sellingPoints : (existingTarget?.sellingPoints || []);
if (sp.length) target.sellingPoints = sp;
if (differentiation) target.differentiation = differentiation;
if (preferredProducts.length) target.preferredProducts = preferredProducts;
if (productFamilies) target.productFamilies = productFamilies;
return target;
}
function funnelDraftFromTarget(target, persona) {
const t = target || persona?.raw;
const parsed = t?.audience
? cfgParseAudience(t.audience)
: { name: persona?.name || "", description: persona?.desc || "", interests: [] };
const selling = t?.sellingPoints || persona?.sellingPoints || [];
return {
name: parsed.name || persona?.name || "",
profile: parsed.description || persona?.desc || "",
motivations: "",
selectedProducts: [...(t?.preferredProducts || persona?.preferredProducts || [])],
productFamilies: t?.productFamilies || persona?.productFamilies || "",
sellingPoints: [selling[0] || "", selling[1] || "", selling[2] || ""],
differentiation: t?.differentiation || persona?.differentiation || "",
};
}
function SegmentFunnelModal({
mode = "create",
editIndex = 0,
targets = [],
personas = [],
featuredProducts = [],
onClose,
onSaved,
}) {
const existingTarget = mode === "enrich" ? targets[editIndex] : null;
const existingPersona = personas[editIndex];
const initialDraft = funnelDraftFromTarget(existingTarget, existingPersona);
const initialDraftRef = useR(initialDraft);
const [step, setStep] = useS(0);
const [draft, setDraft] = useS(initialDraft);
const [productQuery, setProductQuery] = useS("");
const [catalogHits, setCatalogHits] = useS([]);
const [catalogLoading, setCatalogLoading] = useS(false);
const [synthesizing, setSynthesizing] = useS(false);
const [msgIdx, setMsgIdx] = useS(0);
const [result, setResult] = useS(null);
const [review, setReview] = useS(null);
const [error, setError] = useS("");
const [saving, setSaving] = useS(false);
useE(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
useE(() => {
let interval;
if (synthesizing) {
interval = window.setInterval(
() => setMsgIdx((i) => (i + 1) % SEG_ANALYSIS_MSGS.length),
2600,
);
}
return () => { if (interval) window.clearInterval(interval); };
}, [synthesizing]);
const featuredNames = (featuredProducts || []).map((p) => p.name).filter(Boolean);
const productOptions = [...new Set([...featuredNames, ...catalogHits.map((p) => p.title)])];
async function searchCatalog(q) {
const needle = (q || "").trim();
if (needle.length < 2) {
setCatalogHits([]);
return;
}
setCatalogLoading(true);
try {
const data = await GuyAPI.shopifyProducts(needle);
setCatalogHits(data.products || []);
} catch {
setCatalogHits([]);
} finally {
setCatalogLoading(false);
}
}
function patchDraft(patch) {
setDraft((d) => ({ ...d, ...patch }));
}
function toggleProduct(name) {
const label = (name || "").trim();
if (!label) return;
setDraft((d) => {
const has = d.selectedProducts.includes(label);
return {
...d,
selectedProducts: has
? d.selectedProducts.filter((x) => x !== label)
: [...d.selectedProducts, label].slice(0, 25),
};
});
}
function stepValid(idx) {
if (idx < 4) return true;
return Boolean((review?.target?.audience || "").trim());
}
function applyReviewWithoutAi() {
const built = existingTarget?.audience && !segmentDraftHasChanges(draft, initialDraftRef.current)
? { ...existingTarget }
: buildTargetFromDraft(draft, existingTarget, existingPersona);
const parsed = cfgParseAudience(built.audience || "");
setReview({
target: built,
name: parsed.name || draft.name.trim(),
description: parsed.description || draft.profile.trim(),
tone: built.tone || "",
interests: parsed.interests || existingPersona?.interests || [],
sellingPoints: built.sellingPoints || [],
differentiation: built.differentiation || "",
summary:
mode === "enrich" && existingTarget?.audience
? "Aucune modification détectée — la fiche existante est conservée."
: "Aucune information saisie. Revenez en arrière pour compléter le segment.",
skippedAi: true,
});
}
async function runSynthesis() {
setSynthesizing(true);
setError("");
try {
const data = await GuyAPI.enrichSegment({
mode,
editIndex,
idealClient: {
name: draft.name.trim(),
profile: draft.profile.trim(),
motivations: draft.motivations.trim(),
},
products: {
selected: draft.selectedProducts,
familiesFreeText: draft.productFamilies.trim(),
},
sellingPoints: draft.sellingPoints.map((s) => s.trim()).filter(Boolean),
differentiation: draft.differentiation.trim(),
interestOptions: PERSONA_INTERESTS,
toneOptions: PERSONA_TONES.map((t) => t.label),
});
setResult(data);
const parsed = cfgParseAudience(data.target?.audience || "");
setReview({
target: data.target,
name: parsed.name || data.persona?.name || draft.name,
description: parsed.description || data.persona?.description || "",
tone: data.target?.tone || data.persona?.tone || "",
interests: data.persona?.interests || parsed.interests || [],
sellingPoints: data.target?.sellingPoints || [],
differentiation: data.target?.differentiation || "",
summary: data.summary || "",
});
} catch (e) {
setError(e.message || "Synthèse impossible.");
setStep(3);
} finally {
setSynthesizing(false);
}
}
async function goNext() {
if (step < 3) {
setStep((s) => s + 1);
return;
}
if (step === 3) {
setError("");
const hasChanges = segmentDraftHasChanges(draft, initialDraftRef.current);
if (!hasChanges) {
applyReviewWithoutAi();
setStep(4);
return;
}
if (mode === "create" && !segmentDraftHasMinimalContent(draft)) {
applyReviewWithoutAi();
setStep(4);
return;
}
setStep(4);
await runSynthesis();
}
}
function goBack() {
if (step === 0) {
onClose?.();
return;
}
if (step === 4 && synthesizing) return;
setStep((s) => Math.max(0, s - 1));
}
async function saveSegment() {
if (!review?.target?.audience) return;
setSaving(true);
setError("");
try {
const parsed = cfgParseAudience(review.target.audience);
let audience = review.description.trim();
const name = review.name.trim();
if (name && audience) audience = `${name} — ${audience}`;
else if (name) audience = name;
const interests = (review.interests || []).filter(Boolean);
if (interests.length) {
audience = `${audience}${CFG_INTEREST_MARKER}${interests.join(", ")}`;
}
const target = {
...review.target,
audience,
tone: review.tone || review.target.tone || "",
sellingPoints: (review.sellingPoints || []).map((s) => s.trim()).filter(Boolean).slice(0, 3),
differentiation: (review.differentiation || "").trim(),
preferredProducts: draft.selectedProducts,
productFamilies: draft.productFamilies.trim() || undefined,
avatarUrl: existingTarget?.avatarUrl ?? review.target.avatarUrl ?? null,
};
const next = [...targets];
if (mode === "enrich" && editIndex < next.length) {
next[editIndex] = target;
} else {
next.push(target);
}
await GuyAPI.saveConfig({ targetAudiences: next });
onSaved?.();
} catch (e) {
setError(e.message || "Enregistrement impossible.");
} finally {
setSaving(false);
}
}
const progressPct = Math.round(((step + 1) / SEG_FUNNEL_STEPS.length) * 100);
return (
e.stopPropagation()}>
{SEG_FUNNEL_STEPS.map((label, i) => (
{i + 1}. {label}
))}
{error ?
{error}
: null}
{step === 0 && (
)}
{step === 1 && (
Quels produits lui plaisent ?
Sélectionnez dans votre catalogue ou décrivez des familles produits. Facultatif
{featuredNames.length > 0 && (
<>
Produits mis en avant
{featuredNames.map((name) => (
toggleProduct(name)}
>
{name}
))}
>
)}
Rechercher dans le catalogue
{
setProductQuery(e.target.value);
void searchCatalog(e.target.value);
}}
/>
{catalogLoading ? : null}
{productOptions.length > featuredNames.length && (
{catalogHits.map((p) => (
toggleProduct(p.title)}
>
{p.title}
))}
)}
Familles / gammes (texte libre)
patchDraft({ productFamilies: e.target.value })}
/>
)}
{step === 2 && (
3 arguments vendeurs
Pourquoi ce segment devrait acheter — réutilisés dans vos pré-prompts articles. Facultatif
{[0, 1, 2].map((i) => (
Argument {i + 1}
{
const next = [...draft.sellingPoints];
next[i] = e.target.value;
patchDraft({ sellingPoints: next });
}}
/>
))}
)}
{step === 3 && (
Pourquoi chez vous plutôt que chez un concurrent ?
Votre différenciation concrète pour ce segment (service, expertise, promesse…). Facultatif
patchDraft({ differentiation: e.target.value })}
/>
)}
{step === 4 && (
{synthesizing ? (
{SEG_ANALYSIS_MSGS[msgIdx]}
Synthèse persona en cours…
) : review ? (
<>
{review.skippedAi ? "Fiche persona — relecture" : "Fiche persona — synthèse IA"}
{review.summary}
Nom
setReview({ ...review, name: e.target.value })} />
Description
setReview({ ...review, description: e.target.value })} />
Intentions éditoriales
{(PERSONA_INTERESTS || []).map((label) => (
{
const list = review.interests || [];
const has = list.includes(label);
setReview({
...review,
interests: has ? list.filter((x) => x !== label) : [...list, label],
});
}}
>
{label}
))}
Ton
{(PERSONA_TONES || []).map((t) => (
setReview({ ...review, tone: t.label })}
>
{t.label}
))}
Arguments vendeurs
{(review.sellingPoints || []).map((pt, i) => (
{
const next = [...(review.sellingPoints || [])];
next[i] = e.target.value;
setReview({ ...review, sellingPoints: next });
}}
/>
))}
Différenciation
setReview({ ...review, differentiation: e.target.value })}
/>
>
) : null}
)}
{step === 0 ? "Annuler" : "← Retour"}
{step < 4 ? (
void goNext()}
>
{step === 3 ? "Terminer" : "Continuer"}
) : (
void saveSegment()}
>
{saving ? "Enregistrement…" : "Enregistrer le segment"}
)}
);
}
const PLAN_FUNNEL_STEPS = ["Périodes clés", "Événements", "Publication", "Calendrier 12 sem."];
const PLAN_ANALYSIS_MSGS = [
"Guy croise personas et saisonnalité…",
"Alignement sur la Search Console…",
"Construction du calendrier 12 semaines…",
];
function formatSlotsLabel(slots) {
const days = ["", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
if (!slots?.length) return "Non configuré";
return slots
.map((s) => {
const m = s.minute ? `:${String(s.minute).padStart(2, "0")}` : "";
return `${days[s.weekday] || "?"} ${String(s.hour).padStart(2, "0")}h${m}`;
})
.join(" · ");
}
function PublicationSchedulePanel({
schedule,
publicationScheduleAllowed = false,
editorialPlanAllowed = false,
editorialQuota,
onSaveSchedule,
onSaved,
busy = false,
}) {
const scheduleAllowed = publicationScheduleAllowed || editorialPlanAllowed;
const sched = schedule || {};
const [saving, setSaving] = useS(false);
const [error, setError] = useS("");
const [liabilityOpen, setLiabilityOpen] = useS(false);
const reviewMode = !!sched.enabled && !sched.publishWithoutReview;
const fullAuto = !!sched.enabled && !!sched.publishWithoutReview;
const articlesPerWeek = editorialQuota?.articlesPerWeek ?? 2;
const slotLabel = formatSlotsLabel(sched.slots);
function buildPayload(overrides) {
return {
enabled: sched.enabled ?? false,
timezone: sched.timezone || "Europe/Paris",
cadence: sched.cadence || "weekly",
biweeklyOnEvenIsoWeeks: sched.biweeklyOnEvenIsoWeeks !== false,
slots: sched.slots?.length
? sched.slots
: [{ weekday: 2, hour: 9, minute: 0 }],
requireReviewBeforePublish: true,
publishWithoutReview: false,
liabilityAcceptedAt: null,
lastProcessedSlotKey: sched.lastProcessedSlotKey ?? null,
...overrides,
};
}
async function persist(payload) {
if (!onSaveSchedule) return;
setSaving(true);
setError("");
try {
await onSaveSchedule(payload);
onSaved?.();
} catch (e) {
setError(e.message || "Enregistrement impossible.");
} finally {
setSaving(false);
}
}
function slotsForPlan() {
const suggested = editorialQuota?.suggestedPublicationSlots;
if (suggested?.length) return suggested;
if (sched.slots?.length >= articlesPerWeek) return sched.slots;
return sched.slots?.length ? sched.slots : [{ weekday: 2, hour: 9, minute: 0 }];
}
function enableReviewMode() {
void persist(
buildPayload({
enabled: true,
publishWithoutReview: false,
requireReviewBeforePublish: true,
liabilityAcceptedAt: null,
slots: slotsForPlan(),
}),
);
}
function disableReviewMode() {
if (fullAuto) return;
void persist(
buildPayload({
enabled: false,
publishWithoutReview: false,
requireReviewBeforePublish: true,
liabilityAcceptedAt: null,
}),
);
}
function confirmFullAuto() {
setLiabilityOpen(false);
void persist(
buildPayload({
enabled: true,
publishWithoutReview: true,
requireReviewBeforePublish: false,
liabilityAcceptedAt: new Date().toISOString(),
slots: slotsForPlan(),
}),
);
}
function disableFullAuto() {
void persist(
buildPayload({
enabled: false,
publishWithoutReview: false,
requireReviewBeforePublish: true,
liabilityAcceptedAt: null,
}),
);
}
return (
<>
Créneaux automatiques
{slotLabel} — mode relecture avant publication.
{articlesPerWeek > 2 ? ` Aligné sur ${articlesPerWeek} art./sem.` : ""}
(reviewMode ? disableReviewMode() : enableReviewMode())}
>
Mode 100 % automatique
Publication sans relecture — clause de responsabilité requise.
(fullAuto ? disableFullAuto() : setLiabilityOpen(true))}
>
{error ? {error}
: null}
{saving ? Enregistrement…
: null}
{liabilityOpen ? (
setLiabilityOpen(false)}>
e.stopPropagation()}
role="dialog"
aria-labelledby="sg-liability-title"
>
Clause de responsabilité
En activant le mode 100 % automatique, SEO Guy enchaîne titres, articles, visuels et
programmation sans validation humaine. Vous acceptez la responsabilité du contenu publié.
setLiabilityOpen(false)}>
Annuler
J'accepte et j'active
) : null}
>
);
}
function ManualEditorialPlanPanel({
markdown = "",
onSave,
onSaved,
busy = false,
}) {
const [text, setText] = useS(markdown || "");
const [saving, setSaving] = useS(false);
const [error, setError] = useS("");
const [saved, setSaved] = useS(false);
useE(() => {
setText(markdown || "");
}, [markdown]);
async function savePlan() {
if (!onSave) return;
setSaving(true);
setError("");
setSaved(false);
try {
await onSave({ editorialPlanMarkdown: text.trim() });
setSaved(true);
onSaved?.();
} catch (e) {
setError(e.message || "Enregistrement impossible.");
throw e;
} finally {
setSaving(false);
}
}
const isDirty = (text || "").trim() !== (markdown || "").trim();
useUnsavedChangesGuard({
isDirty,
onSave: savePlan,
onDiscard: () => setText(markdown || ""),
});
return (
<>
Renseignez votre calendrier éditorial sous forme de tableau markdown :
semaine, date, thème, mot-clé, persona, angle… Guy s'en sert pour orienter les suggestions de titres.
Plan éditorial (markdown)
{
setText(e.target.value);
setSaved(false);
}}
/>
{error ? {error}
: null}
{saved ? Plan enregistré.
: null}
void savePlan()}
>
{saving ? "Enregistrement…" : "Enregistrer le plan"}
>
);
}
function defaultPlanPublicationPrefs(schedule, editorialQuota) {
const suggested = editorialQuota?.suggestedPublicationSlots;
const slots = schedule?.slots?.length ? schedule.slots : (suggested?.length ? suggested : [{ weekday: 2, hour: 9 }]);
return {
weekdays: [...new Set(slots.map((s) => s.weekday))],
hours: [...new Set(slots.map((s) => s.hour))],
timezone: schedule?.timezone || "Europe/Paris",
notes: "",
};
}
function EditorialPlanFunnelModal({
personas = [],
schedule,
editorialQuota,
gscConnected = false,
onClose,
onSaved,
}) {
const pubDefaults = defaultPlanPublicationPrefs(schedule, editorialQuota);
const [step, setStep] = useS(0);
const [periods, setPeriods] = useS([]);
const [periodsText, setPeriodsText] = useS("");
const [retailEvents, setRetailEvents] = useS([]);
const [customEvents, setCustomEvents] = useS("");
const [weekdays, setWeekdays] = useS(pubDefaults.weekdays);
const [hours, setHours] = useS(pubDefaults.hours);
const [pubNotes, setPubNotes] = useS("");
const [generating, setGenerating] = useS(false);
const [msgIdx, setMsgIdx] = useS(0);
const [result, setResult] = useS(null);
const [error, setError] = useS("");
const [saving, setSaving] = useS(false);
useE(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
useE(() => {
let interval;
if (generating) {
interval = window.setInterval(
() => setMsgIdx((i) => (i + 1) % PLAN_ANALYSIS_MSGS.length),
2600,
);
}
return () => { if (interval) window.clearInterval(interval); };
}, [generating]);
function togglePeriod(id) {
setPeriods((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
function toggleRetail(id) {
setRetailEvents((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
function toggleWeekday(v) {
setWeekdays((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v].sort()));
}
function toggleHour(h) {
setHours((prev) => (prev.includes(h) ? prev.filter((x) => x !== h) : [...prev, h].sort()));
}
async function runGeneration() {
setGenerating(true);
setError("");
try {
const data = await GuyAPI.editorialPlanGenerateCalendar({
importantPeriods: periods,
periodsFreeText: periodsText.trim(),
selectedRetailEvents: retailEvents,
customEvents: customEvents.trim(),
publicationPreferences: {
weekdays: weekdays.length ? weekdays : [2, 4],
hours: hours.length ? hours : [9],
timezone: pubDefaults.timezone,
notes: pubNotes.trim(),
},
});
setResult(data);
} catch (e) {
setError(e.message || "Génération impossible.");
setStep(2);
} finally {
setGenerating(false);
}
}
async function goNext() {
if (step < 2) {
setStep((s) => s + 1);
return;
}
if (step === 2) {
setStep(3);
await runGeneration();
}
}
function goBack() {
if (step === 0) {
onClose?.();
return;
}
if (step === 3 && generating) return;
setStep((s) => Math.max(0, s - 1));
}
async function savePlan() {
if (!result?.editorialPlanMarkdown) return;
setSaving(true);
setError("");
try {
await GuyAPI.editorialPlanApply({ editorialPlanMarkdown: result.editorialPlanMarkdown });
onSaved?.();
} catch (e) {
setError(e.message || "Enregistrement impossible.");
} finally {
setSaving(false);
}
}
const progressPct = Math.round(((step + 1) / PLAN_FUNNEL_STEPS.length) * 100);
return (
e.stopPropagation()}>
{PLAN_FUNNEL_STEPS.map((label, i) => (
{i + 1}. {label}
))}
{error ?
{error}
: null}
{personas.length > 0 && step < 3 && (
Personas & intentions pris en compte
{personas.slice(0, 3).map((p) => (
{p.name}
{(p.interests || []).length ? ` · ${p.interests.slice(0, 2).join(", ")}` : ""}
))}
)}
{step === 0 && (
Quelles périodes de l'année comptent pour vous ?
Saisons commerciales et moments forts de votre activité. Facultatif
{(EDITORIAL_SEASON_PERIODS || []).map((p) => (
togglePeriod(p.id)}
>
{p.label}
))}
Autres périodes importantes
setPeriodsText(e.target.value)}
/>
)}
{step === 1 && (
Événements, salons et temps forts retail
Calendrier national + vos événements propre à votre boutique. Facultatif
Événements du calendrier
{(EDITORIAL_RETAIL_EVENTS || []).map((ev) => (
toggleRetail(ev.id)}
>
{ev.label}
))}
Salons, soldes, opérations spéciales
setCustomEvents(e.target.value)}
/>
)}
{step === 2 && (
Jours et heures de publication
{editorialQuota ? (
Quota de votre plan
{editorialQuota.articlesPerWeek} article(s)/semaine
{" · "}
{editorialQuota.totalBlogEntries12Weeks} entrées blog sur 12 sem.
{editorialQuota.interviewsIn12Weeks > 0 ? (
<>
{" · "}
{editorialQuota.interviewRhythmLabel}
>
) : null}
{
const suggested = editorialQuota.suggestedPublicationSlots || [];
if (!suggested.length) return;
setWeekdays([...new Set(suggested.map((s) => s.weekday))].sort());
setHours([...new Set(suggested.map((s) => s.hour))].sort());
}}
>
Aligner les créneaux sur le plan ({editorialQuota.articlesPerWeek}/sem.)
) : null}
Guy s'appuie aussi sur la Search Console{gscConnected ? " (connectée)" : ""} et vos performances Shopify pour affiner les créneaux.
Facultatif
Jours préférés
{(EDITORIAL_WEEKDAYS || []).map((d) => (
toggleWeekday(d.v)}
>
{d.label}
))}
Heures préférées
{(EDITORIAL_HOUR_OPTIONS || []).map((h) => (
toggleHour(h)}
>
{String(h).padStart(2, "0")} h
))}
Fuseau : {pubDefaults.timezone}
Notes de rythme
setPubNotes(e.target.value)}
/>
)}
{step === 3 && (
{generating ? (
{PLAN_ANALYSIS_MSGS[msgIdx]}
Génération du calendrier…
) : result ? (
<>
Proposition — 12 semaines
{result.summary}
{result.publicationInsights ? (
{result.publicationInsights}
) : null}
{(result.sourcesUsed || []).length > 0 && (
Sources : {(result.sourcesUsed || []).join(" · ")}
)}
{(result.planQuota || editorialQuota) && (
Quota appliqué : {(result.planQuota || editorialQuota).articlesPerWeek} art./sem.
{(result.planQuota || editorialQuota).interviewsIn12Weeks > 0
? ` · ${(result.planQuota || editorialQuota).interviewsIn12Weeks} interview(s) planifiée(s)`
: ""}
)}
{(result.entries || []).map((row, i) => (
))}
>
) : null}
)}
{step === 0 ? "Annuler" : "← Retour"}
{step < 3 ? (
void goNext()}>
{step === 2 ? "Générer le calendrier" : "Continuer"}
) : (
void savePlan()}
>
{saving ? "Enregistrement…" : "Enregistrer le plan"}
)}
);
}
function EditorialPlanEntry({ row, className = "" }) {
const isInterview = String(row.format || "").toLowerCase() === "interview";
const week = row.week || row.weekLabel || "—";
const date =
row.date ||
[row.publishDate, row.publishTime].filter(Boolean).join(" · ") ||
"";
const intentions =
row.intentions ||
(Array.isArray(row.personaIntentions) ? row.personaIntentions.join(", ") : "");
const event = row.event || row.linkedEvent;
const priority = row.priority || "P2";
const intent = row.intent || "Informative";
return (
{week}
{date ? {date} : null}
{priority}
{row.theme || "—"}
{row.format ? (
{row.format}
) : null}
{row.keyword ? {row.keyword} : null}
{row.persona || intentions ? (
{row.persona ? {row.persona} : null}
{row.persona && intentions ? " · " : null}
{intentions || null}
) : null}
{event ?
{event} : null}
{row.angle ?
{row.angle}
: null}
{intent}
);
}
function AssistantPlanScreen({
onBack,
plan,
schedule,
editorialPlanAllowed = false,
publicationScheduleAllowed = false,
planCode = "",
editorialPlanMarkdown = "",
editorialQuota,
personas = [],
gscConnected = false,
planLabel = "",
onOpenFunnel,
onSavePlan,
onPlanSaved,
onSaveSchedule,
onScheduleSaved,
busy = false,
}) {
const isStarterManual = planCode === "starter" || (publicationScheduleAllowed && !editorialPlanAllowed);
const timeline = plan || EDITORIAL_PLAN;
const weeksCount = timeline.length;
const sched = schedule || {};
const articlesPerWeek = editorialQuota?.articlesPerWeek;
const stats = [
{
label: "Articles / semaine",
value: articlesPerWeek != null ? String(articlesPerWeek) : "—",
hint: isStarterManual
? "4 articles · lun.–jeu. à 9 h par défaut"
: editorialQuota
? `${editorialQuota.totalBlogEntries12Weeks} entrées blog sur 12 sem.`
: "Selon votre abonnement",
icon: "spark",
},
{
label: "Rythme interviews",
value: editorialQuota?.interviewsIn12Weeks
? `${editorialQuota.interviewsIn12Weeks} / 12 sem.`
: "—",
hint: editorialQuota?.interviewRhythmLabel || "Non inclus dans le plan",
icon: "mic",
},
{
label: "Semaines planifiées",
value: String(weeksCount),
hint: "Horizon calendrier éditorial",
icon: "calendar",
},
{
label: "Search Console",
value: gscConnected ? "Connectée" : "—",
hint: gscConnected ? "Requêtes utilisées pour les suggestions" : "Connectez la GSC pour affiner",
icon: "chart",
},
];
return (
Plan éditorial >}
sub={
isStarterManual
? "Plan Starter : calendrier à saisir manuellement et créneaux lun.–jeu. à 9 h par défaut (1 article/jour)."
: "Calendrier 12 semaines : thématiques, formats, dates, personas, intentions et événements — enrichi par la GSC et vos performances."
}
/>
{isStarterManual && (
L'assistant IA calendrier est réservé aux plans Pro et Business. Sur Starter, complétez le tableau ci-dessous et activez le planning de publication.
)}
{!editorialPlanAllowed && !publicationScheduleAllowed && (
Passez à un plan Starter ou supérieur pour configurer votre plan éditorial et vos créneaux de publication.
)}
{stats.map((s) => (
{s.value}
{s.label}
{s.hint}
))}
{planLabel || (isStarterManual ? "Starter" : "12 semaines")}
{isStarterManual ? (
<>
Plan éditorial manuel
>
) : (
<>
Générer mon calendrier éditorial
Parcours guidé en 3 étapes puis synthèse IA : périodes clés, événements retail,
créneaux de publication et proposition de contenus sur 12 semaines.
Personas, intentions et quota du plan
Saint-Valentin, Noël, Black Friday, vacances…
GSC + analytics Shopify pour les thèmes
onOpenFunnel?.()}
>
Lancer l'assistant plan
>
)}
{sched.enabled ? (sched.publishWithoutReview ? "100 % auto" : "Relecture") : "Inactif"}
Planning de publication
Choisissez un mode : créneaux avec relecture, ou publication entièrement automatique.
Les deux options sont mutuellement exclusives.
Vue planning ({weeksCount} entrée{weeksCount > 1 ? "s" : ""})
{timeline.map((row, i) => (
))}
← Retour aux assistants
);
}
function GscScreen({ live, onRefresh, busy }) {
const connected = live?.connected ?? false;
const email = live?.email || null;
const properties = live?.properties || [];
const selectedProperty = live?.selectedPropertyUrl || null;
// `live` absent = mode démo statique ; sinon toujours les données réelles.
const queries = live ? (live.queries || []) : GSC_QUERIES;
const [connecting, setConnecting] = useS(false);
const [error, setError] = useS("");
const [pendingProperty, setPendingProperty] = useS("");
const [selectedQueries, setSelectedQueries] = useS({});
const [importing, setImporting] = useS(false);
const [importInfo, setImportInfo] = useS("");
const [disconnecting, setDisconnecting] = useS(false);
async function connect() {
setConnecting(true);
setError("");
try {
const body = await GuyAPI.gscConnectInit();
if (!body.authUrl) {
setError("Connexion indisponible pour le moment.");
setConnecting(false);
return;
}
const popup = window.open(body.authUrl, "gsc-oauth", "popup,width=520,height=680,scrollbars=yes");
if (!popup) {
setError("Popup bloquée — autorisez les popups pour ce site puis réessayez.");
setConnecting(false);
return;
}
const onMessage = (e) => {
if (typeof e.data === "object" && e.data?.type === "gsc:oauth") {
window.removeEventListener("message", onMessage);
setConnecting(false);
if (e.data.payload?.ok) {
onRefresh?.();
} else {
setError(e.data.payload?.error ? `Échec OAuth : ${e.data.payload.error}` : "Échec OAuth Google.");
}
}
};
window.addEventListener("message", onMessage);
const watch = window.setInterval(() => {
if (popup.closed) {
window.clearInterval(watch);
window.removeEventListener("message", onMessage);
setConnecting(false);
}
}, 800);
} catch (err) {
setConnecting(false);
setError(err.message || "Connexion impossible.");
}
}
async function chooseProperty(propertyUrl) {
setPendingProperty(propertyUrl);
setError("");
try {
await GuyAPI.gscSetProperty(propertyUrl);
await onRefresh?.();
} catch (err) {
setError(err.message || "Sélection impossible.");
} finally {
setPendingProperty("");
}
}
async function disconnect() {
setDisconnecting(true);
setError("");
try {
await GuyAPI.gscDisconnect();
await onRefresh?.();
} catch (err) {
setError(err.message || "Déconnexion impossible.");
} finally {
setDisconnecting(false);
}
}
function toggleQuery(query) {
setSelectedQueries((prev) => ({ ...prev, [query]: !prev[query] }));
setImportInfo("");
}
const selectedCount = Object.values(selectedQueries).filter(Boolean).length;
async function importSelected() {
const items = queries
.filter((q) => selectedQueries[q.query])
.map((q) => ({
keyword: q.query,
clicks: q.clicks,
impressions: q.impressions,
position: q.position,
}));
if (items.length === 0) return;
setImporting(true);
setError("");
try {
const res = await GuyAPI.gscAddKeywords({ items });
setImportInfo(`${res.inserted ?? items.length} mot(s)-clé(s) importé(s).`);
setSelectedQueries({});
} catch (err) {
setError(err.message || "Import impossible.");
} finally {
setImporting(false);
}
}
const fmtNum = (v) => (typeof v === "number" ? v.toLocaleString("fr-FR") : v ?? "—");
const fmtPos = (v) => (typeof v === "number" ? v.toFixed(1) : "—");
const fmtCtr = (v) => (typeof v === "number" ? `${v.toFixed(1).replace(".", ",")} %` : "—");
return (
Google Search Console >}
sub="Connexion OAuth · sélection de propriété · import de mots-clés · sync métriques articles."
status={connected ? "Connectée" : "À connecter"}
/>
{error && {error}
}
{!connected ? (
G
Connecter votre compte Google
Active visites SEO, position moyenne, CTR et opportunités de mots-clés.
{connecting ? (<> Connexion…>) : "Connecter la GSC"}
) : (
<>
G
Compte Google connecté
{email && {email} }
{disconnecting ? "Déconnexion…" : "Déconnecter"}
{selectedProperty ? "Propriété GSC utilisée" : "Propriété GSC à utiliser"}
{selectedProperty ? (
<>
{selectedProperty}
Verrouillée
Pour changer de propriété, déconnectez le compte Google puis reconnectez-vous.
>
) : properties.length === 0 ? (
Aucune propriété accessible avec ce compte Google.
) : (
{properties.map((p) => (
chooseProperty(p.siteUrl)}
/>
{p.siteUrl}
{pendingProperty === p.siteUrl ? "Activation…" : p.permissionLevel}
))}
)}
{selectedProperty ? (
Requêtes Search Console (90 derniers jours)
{importing ? "Import…" : `Importer en mots-clés (${selectedCount})`}
{importInfo && {importInfo}
}
{queries.length === 0 ? (
Aucune requête sur la période pour cette propriété.
) : (
<>
Requête Clics Impressions CTR Position
{queries.map((q) => (
toggleQuery(q.query)}
/>
{q.query}
{fmtNum(q.clicks)}
{fmtNum(q.impressions)}
{fmtCtr(q.ctr)}
{fmtPos(q.position)}
))}
>
)}
) : (
Sélectionnez une propriété pour charger les requêtes Search Console.
)}
>
)}
);
}
const CFG_TARGETS_MAX = 3;
const CFG_PRODUCTS_MAX = 200;
const CFG_INTEREST_MARKER = " · Recherche : ";
function cfgParseAudience(audience) {
const raw = (audience || "").trim();
const markerIdx = raw.indexOf("Recherche :");
let base = raw;
let interests = [];
if (markerIdx !== -1) {
base = raw.slice(0, markerIdx).replace(/[·•|\-\s]+$/, "").trim();
interests = raw
.slice(markerIdx + "Recherche :".length)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
const parts = base.split(/\s*—\s*/).map((p) => p.trim()).filter(Boolean);
let name = "";
let description = base;
if (parts.length >= 2) {
name = parts[0];
description = parts.slice(1).join(" — ");
} else if (parts.length === 1) {
description = parts[0];
}
return { name, description, interests };
}
function cfgTargetToRow(t) {
const { name, description, interests } = cfgParseAudience(t.audience);
return {
name,
description,
tone: t.tone || "",
interests,
avatarUrl: t.avatarUrl || null,
sellingPoints: t.sellingPoints || [],
differentiation: t.differentiation || "",
preferredProducts: t.preferredProducts || [],
productFamilies: t.productFamilies || "",
};
}
function cfgRowToTarget(row) {
const name = (row.name || "").trim();
const desc = (row.description || "").trim();
const tone = (row.tone || "").trim();
const interests = Array.isArray(row.interests) ? row.interests.filter(Boolean) : [];
let audience = desc;
if (name && desc) audience = `${name} — ${desc}`;
else if (name) audience = name;
if (!audience) return null;
if (interests.length) audience = `${audience}${CFG_INTEREST_MARKER}${interests.join(", ")}`;
const sellingPoints = (row.sellingPoints || []).map((s) => (s || "").trim()).filter(Boolean).slice(0, 3);
const preferredProducts = (row.preferredProducts || []).map((s) => (s || "").trim()).filter(Boolean).slice(0, 25);
const differentiation = (row.differentiation || "").trim();
const productFamilies = (row.productFamilies || "").trim();
const target = {
audience,
tone,
avatarUrl: row.avatarUrl?.trim() || null,
};
if (sellingPoints.length) target.sellingPoints = sellingPoints;
if (differentiation) target.differentiation = differentiation;
if (preferredProducts.length) target.preferredProducts = preferredProducts;
if (productFamilies) target.productFamilies = productFamilies;
return target;
}
function cfgSignature(cfg) {
return JSON.stringify({
t: cfg?.targetAudiences ?? [],
p: cfg?.featuredProducts ?? [],
c: cfg?.editorialCharter ?? "",
w: cfg?.writingPrecautions ?? "",
r: cfg?.regulatoryReferences ?? "",
f: cfg?.forbiddenKeywords ?? "",
});
}
function serializeConfigDraft(personaRows, products, charter, precautions) {
const cleanTargets = personaRows.map((r) => cfgRowToTarget(r)).filter(Boolean);
const cleanProducts = (products || [])
.map((p) => {
const row = { name: (p.name || "").trim(), url: (p.url || "").trim() };
const img = (p.imageUrl || "").trim();
if (/^https?:\/\//i.test(img)) row.imageUrl = img;
const sid = (p.shopifyProductId || "").trim();
if (sid) row.shopifyProductId = sid;
return row;
})
.filter((p) => p.name && (p.imageUrl || /^https?:\/\//i.test(p.url)));
return JSON.stringify({
targetAudiences: cleanTargets,
featuredProducts: cleanProducts,
editorialCharter: (charter || "").trim(),
writingPrecautions: (precautions || "").trim(),
});
}
function ConfigScreen({ configuration, onSave, busy, shopifyConnected, wordpressConnected }) {
const cfg = configuration || {};
const catalogConnected = !!(shopifyConnected || wordpressConnected);
const [personaRows, setPersonaRows] = useS(() =>
(cfg.targetAudiences || []).map((t) => cfgTargetToRow(t)),
);
const [products, setProducts] = useS(() => (cfg.featuredProducts || []).map((p) => ({ ...p })));
const [charter, setCharter] = useS(cfg.editorialCharter || "");
const [precautions, setPrecautions] = useS(cfg.writingPrecautions || "");
const [saved, setSaved] = useS(false);
const [productPhotoBusy, setProductPhotoBusy] = useS(null);
const [productPhotoErr, setProductPhotoErr] = useS("");
// Resynchronise quand la config change (ex : après l'assistant ou un enregistrement).
const sig = cfgSignature(cfg);
React.useEffect(() => {
setPersonaRows((cfg.targetAudiences || []).map((t) => cfgTargetToRow(t)));
setProducts((cfg.featuredProducts || []).map((p) => ({ ...p })));
setCharter(cfg.editorialCharter || "");
setPrecautions(cfg.writingPrecautions || "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
function updatePersonaRow(i, patch) {
setPersonaRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function togglePersonaInterest(i, label) {
setPersonaRows((prev) =>
prev.map((r, idx) => {
if (idx !== i) return r;
const list = Array.isArray(r.interests) ? r.interests : [];
const has = list.includes(label);
return { ...r, interests: has ? list.filter((x) => x !== label) : [...list, label] };
}),
);
}
function addPersonaRow() {
if (personaRows.length >= CFG_TARGETS_MAX) return;
setPersonaRows((prev) => [
...prev,
{ name: "", description: "", tone: PERSONA_TONES[0]?.label || "", interests: [], avatarUrl: null },
]);
}
function removePersonaRow(i) {
setPersonaRows((prev) => prev.filter((_, idx) => idx !== i));
}
function updateTarget(i, field, value) {
updatePersonaRow(i, { [field]: value });
}
function addTarget() {
addPersonaRow();
}
function removeTarget(i) {
removePersonaRow(i);
}
function updateProduct(i, field, value) {
setProducts((prev) => prev.map((p, idx) => (idx === i ? { ...p, [field]: value } : p)));
}
function patchProduct(i, patch) {
setProducts((prev) => prev.map((p, idx) => (idx === i ? { ...p, ...patch } : p)));
}
function addProduct() {
setProducts((prev) => [...prev, { name: "", url: "", imageUrl: "", shopifyProductId: "" }]);
}
function removeProduct(i) {
setProducts((prev) => prev.filter((_, idx) => idx !== i));
}
function catalogProductKey(p) {
const gid = (p.shopifyProductId || p.id || "").trim();
if (gid) return `id:${gid.toLowerCase()}`;
const handle = productHandleFromUrl(p.url);
if (handle) return `h:${handle}`;
const name = (p.name || p.title || "").trim().toLowerCase();
return name ? `n:${name}` : "";
}
function mergeCatalogRows(existing, catalog) {
const out = (existing || []).map((p) => ({ ...p }));
const indexByKey = {};
out.forEach((p, i) => {
const k = catalogProductKey(p);
if (k && indexByKey[k] == null) indexByKey[k] = i;
});
for (const c of catalog || []) {
const row = {
name: (c.title || c.name || "").trim().slice(0, 200),
url: c.url || "",
imageUrl: c.imageUrl || "",
shopifyProductId: c.id || "",
};
if (!row.name) continue;
const k = catalogProductKey(row);
if (k && indexByKey[k] != null) {
const idx = indexByKey[k];
if (!out[idx].imageUrl && row.imageUrl) {
out[idx] = {
...out[idx],
imageUrl: row.imageUrl,
shopifyProductId: out[idx].shopifyProductId || row.shopifyProductId,
};
}
continue;
}
if (out.length >= CFG_PRODUCTS_MAX) break;
if (k) indexByKey[k] = out.length;
out.push(row);
}
return out.slice(0, CFG_PRODUCTS_MAX);
}
function productHandleFromUrl(url) {
const raw = (url || "").trim();
if (!raw) return "";
const m = raw.match(/\/products\/([^/?#]+)/i);
if (!m) return "";
try {
return decodeURIComponent(m[1]).replace(/\/+$/, "").toLowerCase();
} catch {
return m[1].replace(/\/+$/, "").toLowerCase();
}
}
function matchCatalogProduct(list, p) {
const handle = productHandleFromUrl(p.url);
const gid = (p.shopifyProductId || "").trim();
const url = (p.url || "").trim().replace(/\/+$/, "").toLowerCase();
const name = (p.name || "").trim().toLowerCase();
return (list || []).find((c) => {
if (gid && c.id && gid === c.id) return true;
const cHandle = (c.handle || productHandleFromUrl(c.url) || "").toLowerCase();
if (handle && cHandle && handle === cHandle) return true;
const cUrl = (c.url || "").trim().replace(/\/+$/, "").toLowerCase();
const cTitle = (c.title || "").trim().toLowerCase();
if (url && cUrl && (cUrl === url || cUrl.includes(url) || url.includes(cUrl))) return true;
return !!(name && cTitle && cTitle === name);
});
}
async function applyShopifyPhoto(i) {
setProductPhotoErr("");
setProductPhotoBusy(i);
try {
const data = await GuyAPI.shopifyProducts();
const hit = matchCatalogProduct(data.products || [], products[i]);
if (!hit?.imageUrl) {
setProductPhotoErr("Aucune photo catalogue pour ce produit. Importez un packshot ou vérifiez le lien.");
return;
}
patchProduct(i, { imageUrl: hit.imageUrl, shopifyProductId: hit.id || products[i]?.shopifyProductId });
} catch (e) {
setProductPhotoErr(e.message || "Catalogue Shopify inaccessible.");
} finally {
setProductPhotoBusy(null);
}
}
async function fillShopifyPhotos(opts) {
const silent = !!(opts && opts.silent);
if (!silent) setProductPhotoErr("");
setProductPhotoBusy("all");
try {
const data = await GuyAPI.shopifyProducts();
const list = data.products || [];
const next = mergeCatalogRows(products, list);
const changed = JSON.stringify(next) !== JSON.stringify(products);
setProducts(next);
if (changed) {
persistFilledPhotos(next);
if (!silent) {
setSaved(true);
setTimeout(() => setSaved(false), 2500);
}
} else if (!silent) {
setProductPhotoErr(
list.length
? "Le catalogue est déjà à jour."
: "Aucun produit actif trouvé dans Shopify / WooCommerce.",
);
}
} catch (e) {
if (!silent) setProductPhotoErr(e.message || "Catalogue inaccessible.");
} finally {
setProductPhotoBusy(null);
}
}
const autoPhotoTried = React.useRef(false);
React.useEffect(() => {
if (autoPhotoTried.current) return;
if (!catalogConnected) return;
autoPhotoTried.current = true;
fillShopifyPhotos({ silent: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [catalogConnected, sig]);
async function onProductPhotoFile(i, file) {
if (!file) return;
setProductPhotoErr("");
setProductPhotoBusy(i);
try {
const dataUrl = await resizeProductRefFile(file);
const res = await GuyAPI.uploadFeaturedProductImage(dataUrl);
if (!res.url) throw new Error("Hébergement de la photo impossible.");
patchProduct(i, { imageUrl: res.url });
} catch (e) {
setProductPhotoErr(e.message || "Import de la photo impossible.");
} finally {
setProductPhotoBusy(null);
}
}
function nameFromVisualFile(file) {
const raw = (file.name || "Visuel").replace(/\.[^.]+$/, "");
const cleaned = raw.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
if (!cleaned) return "Visuel";
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
async function onBulkVisualFiles(fileList) {
const files = Array.from(fileList || []).filter((f) =>
/^image\/(jpeg|jpg|png|webp)$/i.test(f.type),
);
if (!files.length) {
setProductPhotoErr("Choisissez des JPEG, PNG ou WebP.");
return;
}
const room = CFG_PRODUCTS_MAX - products.length;
if (room <= 0) {
setProductPhotoErr("Limite atteinte (200 visuels).");
return;
}
const take = files.slice(0, Math.min(10, room));
setProductPhotoErr("");
setProductPhotoBusy("bulk");
const added = [];
try {
for (let i = 0; i < take.length; i += 1) {
setProductPhotoErr(`Import ${i + 1} / ${take.length}…`);
const file = take[i];
const dataUrl = await resizeProductRefFile(file);
const res = await GuyAPI.uploadFeaturedProductImage(dataUrl);
if (!res.url) throw new Error("Hébergement du visuel impossible.");
added.push({
name: nameFromVisualFile(file),
url: "",
imageUrl: res.url,
shopifyProductId: "",
});
}
setProducts((prev) => [...prev, ...added]);
setProductPhotoErr(
files.length > take.length
? `${take.length} visuels importés (10 maximum à la fois). Renommez-les puis Enregistrer.`
: `${added.length} visuel${added.length > 1 ? "s" : ""} importé${added.length > 1 ? "s" : ""}. Renommez-les si besoin, puis Enregistrer.`,
);
} catch (e) {
if (added.length) setProducts((prev) => [...prev, ...added]);
setProductPhotoErr(e.message || "Import des visuels impossible.");
} finally {
setProductPhotoBusy(null);
}
}
function persistFilledPhotos(productList) {
const list = productList || products;
const cleanProducts = list
.map((p) => {
const row = { name: (p.name || "").trim(), url: (p.url || "").trim() };
const img = (p.imageUrl || "").trim();
if (/^https?:\/\//i.test(img)) row.imageUrl = img;
const sid = (p.shopifyProductId || "").trim();
if (sid) row.shopifyProductId = sid;
return row;
})
.filter((p) => p.name && (p.imageUrl || /^https?:\/\//i.test(p.url)));
onSave?.({ featuredProducts: cleanProducts });
}
function saveWithProducts(productList) {
const list = productList || products;
const cleanTargets = personaRows.map((r) => cfgRowToTarget(r)).filter(Boolean);
const cleanProducts = list
.map((p) => {
const row = { name: (p.name || "").trim(), url: (p.url || "").trim() };
const img = (p.imageUrl || "").trim();
if (/^https?:\/\//i.test(img)) row.imageUrl = img;
const sid = (p.shopifyProductId || "").trim();
if (sid) row.shopifyProductId = sid;
return row;
})
.filter((p) => p.name && (p.imageUrl || /^https?:\/\//i.test(p.url)));
onSave?.({
targetAudiences: cleanTargets,
featuredProducts: cleanProducts,
editorialCharter: charter,
writingPrecautions: precautions,
});
setSaved(true);
setTimeout(() => setSaved(false), 2500);
}
async function saveAsync() {
const list = products;
const cleanTargets = personaRows.map((r) => cfgRowToTarget(r)).filter(Boolean);
const cleanProducts = list
.map((p) => {
const row = { name: (p.name || "").trim(), url: (p.url || "").trim() };
const img = (p.imageUrl || "").trim();
if (/^https?:\/\//i.test(img)) row.imageUrl = img;
const sid = (p.shopifyProductId || "").trim();
if (sid) row.shopifyProductId = sid;
return row;
})
.filter((p) => p.name && (p.imageUrl || /^https?:\/\//i.test(p.url)));
await onSave?.({
targetAudiences: cleanTargets,
featuredProducts: cleanProducts,
editorialCharter: charter,
writingPrecautions: precautions,
});
setSaved(true);
setTimeout(() => setSaved(false), 2500);
}
const savedDraft = React.useMemo(
() =>
serializeConfigDraft(
(cfg.targetAudiences || []).map((t) => cfgTargetToRow(t)),
(cfg.featuredProducts || []).map((p) => ({ ...p })),
cfg.editorialCharter || "",
cfg.writingPrecautions || "",
),
[sig],
);
const currentDraft = React.useMemo(
() => serializeConfigDraft(personaRows, products, charter, precautions),
[personaRows, products, charter, precautions],
);
const isDirty = savedDraft !== currentDraft;
useUnsavedChangesGuard({
isDirty,
onSave: saveAsync,
onDiscard: () => {
setPersonaRows((cfg.targetAudiences || []).map((t) => cfgTargetToRow(t)));
setProducts((cfg.featuredProducts || []).map((p) => ({ ...p })));
setCharter(cfg.editorialCharter || "");
setPrecautions(cfg.writingPrecautions || "");
},
});
function save() {
saveWithProducts(products);
}
return (
Ciblage marketing >} sub="Personas, produits mis en avant, charte éditoriale et précautions." />
Ces informations sont préremplies par l'Assistant de démarrage. Modifiez-les ici à tout moment.
{busy ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
Clients cibles (personas)
Qui visez-vous ? SEO GUY adapte le ton, les intentions éditoriales et les angles à ces audiences.
{personaRows.length === 0 && (
Aucune cible définie pour le moment.
)}
{personaRows.map((row, i) => (
{row.avatarUrl ? (
) : (
{(row.name || "?").slice(0, 1).toUpperCase()}
)}
updatePersonaRow(i, { name: e.target.value })}
/>
updatePersonaRow(i, { description: e.target.value })}
/>
removeTarget(i)} aria-label="Retirer">✕
Ce qu'il/elle cherche vraiment
{(PERSONA_INTERESTS || []).map((label) => (
togglePersonaInterest(i, label)}
>
{label}
))}
Ton pour ce persona
{(PERSONA_TONES || []).map((t) => (
updatePersonaRow(i, { tone: t.label })}
>
{t.label}
))}
))}
{personaRows.length < CFG_TARGETS_MAX && (
Ajouter une cible
)}
Produits, offres et visuels de référence
Sans boutique e-commerce : importez jusqu’à 10 photos d’un coup (captures d’écran, vues du local, produits).
Le nom du fichier devient le nom — renommez-le comme dans vos titres. Le lien de page est facultatif.
Boutique Shopify ou WooCommerce : jusqu’à 200 produits actifs sont chargés automatiquement, avec leur photo.
= CFG_PRODUCTS_MAX ? " is-disabled" : ""}`}>
{productPhotoBusy === "bulk" ? "Import…" : "Importer jusqu’à 10 photos"}
= CFG_PRODUCTS_MAX}
onChange={(e) => {
const list = e.target.files;
e.target.value = "";
if (list && list.length) onBulkVisualFiles(list);
}}
/>
{catalogConnected ? (
{productPhotoBusy === "all" ? "Récupération…" : "Actualiser le catalogue"}
) : null}
{productPhotoErr && {productPhotoErr}
}
{products.length === 0 && (
Aucune offre ni visuel pour le moment.
)}
{products.map((p, i) => (
{p.imageUrl ? (
) : (
Visuel
)}
))}
Ajouter une offre ou un visuel
Charte éditoriale
Résumé de votre activité, ton, promesses — rédigé par l'assistant, ajustable ici.
setCharter(e.target.value)}
/>
Précautions rédactionnelles
setPrecautions(e.target.value)}
/>
Interdits rédactionnels
Mots-clés, expressions, pack métier et référentiels se gèrent dans Réglages → Interdits rédactionnels.
{cfg.forbiddenKeywords
? " Des expressions interdites sont déjà enregistrées pour ce compte."
: ""}
);
}
function billingPlanPrice(plan, cycle) {
const monthly = plan.monthlyPriceEur ?? 0;
if (!monthly) return { main: "Gratuit", suffix: "" };
const yearly = plan.yearlyPriceEur ?? 0;
const display =
cycle === "yearly" && yearly > 0
? Math.round((yearly / 12) * 10) / 10
: monthly;
return { main: `${display} €`, suffix: "/mois" };
}
function billingYearlySave(plans) {
const list = plans || PLANS;
const hasPaid = list.some((p) => (p.monthlyPriceEur ?? 0) > 0 && (p.yearlyPriceEur ?? 0) > 0);
return hasPaid ? 17 : 0;
}
function billingPlanCta(plan) {
if (plan.pending) return "Confirmation en attente…";
if (plan.current) return "Plan actif";
if (plan.code === "discovery") return "Commencer gratuitement";
return `Choisir ${plan.name}`;
}
function BillingScreen({ plans, current, onSelectPlan, busy }) {
const [cycle, setCycle] = useS("yearly");
const [couponCode, setCouponCode] = useS("");
const planList = (plans || PLANS).filter((p) => p.code !== "business");
const yearlySave = billingYearlySave(planList);
async function choosePlan(code) {
try {
await onSelectPlan?.(code, cycle, couponCode.trim() || undefined);
} catch (e) {
alert(e?.message || "Impossible de changer de plan.");
}
}
return (
{planList.map((plan) => {
const price = billingPlanPrice(plan, cycle);
const isBusiness = plan.code === "business";
return (
{plan.highlighted && !plan.current ? (
Recommandé
) : null}
{plan.pending ? (
En attente
) : null}
{plan.current ? (
Votre plan
) : null}
{plan.letter || plan.name[0]}
{plan.name}
{plan.subtitle || plan.tagline}
{price.main}
{price.suffix ? {price.suffix} : null}
{(plan.features || []).map((feat) => (
{feat.ok ? "✓" : "✕"}
{feat.label}
))}
choosePlan(plan.code)}
>
{plan.current ? : null}
{billingPlanCta(plan)}
);
})}
);
}
function accountProfileFrom(account) {
return {
companyName: account?.companyName || account?.displayName || "",
siret: account?.siret || "",
vatNumber: account?.vatNumber || "",
billingStreet: account?.billingStreet || "",
billingPostalCode: account?.billingPostalCode || "",
billingCity: account?.billingCity || "",
billingCountry: account?.billingCountry || "France",
};
}
function AccountScreen({ sites, account, onSelectSite, onLogout, onRefresh, busy }) {
const siteList = sites || SITES;
const [newShop, setNewShop] = React.useState("");
const [profile, setProfile] = React.useState(() => accountProfileFrom(account));
const [profileInfo, setProfileInfo] = React.useState("");
const [profileError, setProfileError] = React.useState("");
const [savingProfile, setSavingProfile] = React.useState(false);
const [newPassword, setNewPassword] = React.useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = React.useState("");
const [passwordInfo, setPasswordInfo] = React.useState("");
const [passwordError, setPasswordError] = React.useState("");
const [savingPassword, setSavingPassword] = React.useState(false);
React.useEffect(() => {
setProfile(accountProfileFrom(account));
}, [
account?.companyName,
account?.displayName,
account?.siret,
account?.vatNumber,
account?.billingStreet,
account?.billingPostalCode,
account?.billingCity,
account?.billingCountry,
]);
function addShop() {
const raw = newShop.trim().toLowerCase();
if (!raw) return;
const shop = raw.includes(".") ? raw : `${raw}.myshopify.com`;
const apiBase =
typeof API_BASE !== "undefined" && API_BASE
? API_BASE
: `http://localhost:${window.SEOGUY_API_PORT || 3000}`;
window.open(`${apiBase}/auth/login?shop=${encodeURIComponent(shop)}`, "_blank");
}
function updateProfileField(key, value) {
setProfile((prev) => ({ ...prev, [key]: value }));
setProfileInfo("");
setProfileError("");
}
async function saveProfile(e) {
if (e?.preventDefault) e.preventDefault();
setSavingProfile(true);
setProfileError("");
setProfileInfo("");
try {
await GuyAPI.updateAccountProfile(profile);
setProfileInfo("Informations enregistrées.");
await onRefresh?.();
} catch (err) {
setProfileError(err.message || "Enregistrement impossible.");
throw err;
} finally {
setSavingProfile(false);
}
}
async function savePassword(e) {
if (e?.preventDefault) e.preventDefault();
if (newPassword !== newPasswordConfirm) {
setPasswordError("Les mots de passe ne correspondent pas.");
throw new Error("Les mots de passe ne correspondent pas.");
}
setSavingPassword(true);
setPasswordError("");
setPasswordInfo("");
try {
await GuyAPI.changeAccountPassword({
newPassword,
newPasswordConfirm,
});
setPasswordInfo("Mot de passe mis à jour.");
setNewPassword("");
setNewPasswordConfirm("");
} catch (err) {
setPasswordError(err.message || "Changement impossible.");
throw err;
} finally {
setSavingPassword(false);
}
}
const savedProfileJson = React.useMemo(
() => JSON.stringify(accountProfileFrom(account)),
[
account?.companyName,
account?.displayName,
account?.siret,
account?.vatNumber,
account?.billingStreet,
account?.billingPostalCode,
account?.billingCity,
account?.billingCountry,
],
);
const isProfileDirty = JSON.stringify(profile) !== savedProfileJson;
const isPasswordDirty = !!(newPassword.trim() || newPasswordConfirm.trim());
const isDirty = isProfileDirty || isPasswordDirty;
useUnsavedChangesGuard({
isDirty,
onSave: async () => {
if (isProfileDirty) await saveProfile();
if (isPasswordDirty) await savePassword();
},
onDiscard: () => {
setProfile(accountProfileFrom(account));
setNewPassword("");
setNewPasswordConfirm("");
},
});
const webAccount = typeof onLogout === "function";
const saving = busy || savingProfile || savingPassword;
return (
{webAccount && (
<>
>
)}
Boutiques rattachées
{siteList.map((s) => (
onSelectSite?.(s.id)}
style={{ cursor: onSelectSite ? "pointer" : "default" }}
>
{s.name}
{s.domain}
{s.published} publiés
{s.drafts} brouillons
{s.current ? (
Active
) : (
{
e.stopPropagation();
onSelectSite?.(s.id);
}}
>
Activer
)}
))}
);
}
Object.assign(window, {
PerformanceScreen, AssistantsHubScreen, AssistantSegmentScreen, SegmentFunnelModal,
EditorialPlanFunnelModal, AssistantPlanScreen, ManualEditorialPlanPanel, PublicationSchedulePanel, GscScreen, ConfigScreen, BillingScreen, AccountScreen, Sparkline,
});