// SEO GUY — Onboarding 4 étapes (analyse → cibles & plan → GSC → 1er article)
const { useState: useOS, useEffect: useOE, useMemo: useOM, useCallback: useOC } = React;
const OB_STEPS = [
{ id: 1, label: "Analyse" },
{ id: 2, label: "Cibles & plan" },
{ id: 3, label: "Google" },
{ id: 4, label: "1er article" },
];
const OB_RESUME_LABELS = [
"Analyser votre site",
"Définir vos cibles et votre formule",
"Connecter Google",
"Voir votre premier article",
];
const SEO_GUY_CPV_URL = "https://assistantguy.com/legal/cpv-abonnements/";
const WP_PLUGIN_ZIP_URL = "https://assistantguy.com/downloads/assistant-guy.zip";
const AUTOPILOT_MODE_DESCRIPTION =
"SEO GUY rédige, illustre et publie vos articles sur votre site selon votre rythme — sans relecture ni validation préalable de votre part.";
const AUTOPILOT_LIABILITY_NOTICE =
"En activant le pilotage automatique, vous reconnaissez que la publication s'effectue sans contrôle préalable de votre part. Vous demeurez seul responsable des contenus publiés (exactitude, conformité légale et réglementaire, droits des tiers, image de marque) ainsi que de leurs conséquences commerciales, SEO ou juridiques. EMARKETIS ne saurait être tenue responsable des contenus diffusés en mode automatique, dans les limites autorisées par les Conditions particulières de vente.";
const AUTOPILOT_LIABILITY_CHECKBOX_LABEL =
"J'accepte que la publication automatique relève de ma seule responsabilité et je décharge EMARKETIS de toute responsabilité au titre des contenus publiés sans relecture préalable, conformément à l'article 8 des Conditions particulières de vente.";
function defaultAutopilotSlots(planCode) {
if (!planCode || planCode === "discovery") {
return [{ weekday: 1, hour: 9, minute: 0 }];
}
if (planCode === "starter") {
return [1, 2, 3, 4].map((weekday) => ({ weekday, hour: 9, minute: 0 }));
}
const slots = [];
for (const weekday of [1, 2, 3, 4]) {
slots.push({ weekday, hour: 12, minute: 0 });
slots.push({ weekday, hour: 18, minute: 0 });
}
return slots;
}
function parseAudienceLabel(audience) {
const raw = (audience || "").trim();
if (!raw) return { name: "", description: "" };
const parts = raw.split(/\s*—\s*/).map((p) => p.trim()).filter(Boolean);
if (parts.length <= 1) return { name: "", description: parts[0] || "" };
if (parts.length >= 2 && parts[0] === parts[1]) {
return { name: parts[0], description: parts.slice(2).join(" — ") || parts[1] };
}
return { name: parts[0], description: parts.slice(1).join(" — ") };
}
const PERSONA_INTEREST_MARKER = " · Recherche : ";
function splitInterests(text) {
const raw = (text || "").trim();
const idx = raw.indexOf(PERSONA_INTEREST_MARKER.trim());
if (idx === -1) return { description: raw, interests: [] };
// On coupe sur le marqueur (avec ou sans espaces autour).
const markerIdx = raw.indexOf("Recherche :");
const description = raw.slice(0, markerIdx).replace(/[·•|\-\s]+$/, "").trim();
const interests = raw
.slice(markerIdx + "Recherche :".length)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return { description, interests };
}
function defaultPersonasFromAudiences(audiences) {
const ids = ["p1", "p2", "p3"];
return ids.map((id, i) => {
const preset = PERSONA_PRESETS[i % PERSONA_PRESETS.length];
const raw = audiences[i];
if (!raw) {
return {
id,
name: preset.label,
description: "",
tone: i === 0 ? PERSONA_TONES[0].label : "",
interests: [],
customPhotoUrl: null,
};
}
const audienceText = raw.audience || raw.desc || "";
const { description: cleaned, interests } = splitInterests(audienceText);
const { name, description } = parseAudienceLabel(cleaned);
return {
id,
name: raw.name || name || preset.label,
description: raw.desc || description,
tone: raw.tone || PERSONA_TONES[0].label,
interests: Array.isArray(raw.interests) ? raw.interests : interests,
customPhotoUrl: raw.avatarUrl || null,
};
});
}
function personasToTargets(personas) {
return personas
.map((p) => {
const name = (p.name || "").trim();
const desc = (p.description || "").trim();
const tone = (p.tone || "").trim();
const interests = Array.isArray(p.interests) ? p.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}${PERSONA_INTEREST_MARKER}${interests.join(", ")}`;
const avatarUrl = p.customPhotoUrl?.trim() || null;
return { audience, tone, avatarUrl };
})
.filter(Boolean);
}
// Limite de personas affichables/éditables dans l'assistant. Pour un plan
// "illimité" on plafonne l'UI à 6 (au-delà ce n'est pas pertinent en onboarding).
const SEGMENTS_UI_CAP = 6;
function segmentLimitForPlan(code, plans) {
const plan = (plans || []).find((p) => p.code === code);
const feat = plan?.raw?.features?.segmentsCount;
if (!feat) return 3;
if (feat.kind === "limit") return feat.limit === -1 ? SEGMENTS_UI_CAP : Math.max(1, feat.limit);
return 3;
}
const PLAN_FEATURE_LABELS = {
articlesPerWeek: "Articles par semaine",
interviewsPerPeriod: "Interviews incluses",
keywordsCount: "Mots-clés en base",
segmentsCount: "Segments éditoriaux",
competitorsCount: "Concurrents suivis",
googleSearchConsole: "Google Search Console",
editorialPlan: "Plan éditorial auto",
seoRevenueAttribution: "Attribution CA SEO",
proSeoAnalysis: "Analyse SEO Pro",
aiKeywordsSuggest: "IA mots-clés",
expertInterviews: "Interviews expert",
prioritySupport: "Support prioritaire",
};
function featureLimitLabel(feat) {
if (!feat) return "—";
if (feat.kind === "bool") return feat.enabled ? "Inclus" : "—";
if (feat.limit === -1) return "Illimité";
return String(feat.limit);
}
function planArticlesLabel(plan) {
const f = plan.raw?.features?.articlesPerWeek;
if (!f || f.kind !== "limit") return "—";
return f.limit === -1 ? "Illimité" : `${f.limit} / semaine`;
}
function planInterviewsLabel(plan) {
const f = plan.raw?.features?.interviewsPerPeriod;
if (!f || f.kind !== "limit" || f.limit === 0) return "À l'unité (5 €)";
if (plan.code === "starter") return `${f.limit} / trimestre`;
return `${f.limit} / mois`;
}
function planPriceLabel(plan, cycle) {
const parsed = parseInt(String(plan.price).replace(/\D/g, ""), 10);
const monthly = plan.raw?.monthlyPriceEur ?? (Number.isFinite(parsed) ? parsed : 0);
const yearly = plan.raw?.yearlyPriceEur;
if (monthly === 0) return "0 €";
if (cycle === "yearly" && yearly) return `${Math.round(yearly / 12)} €`;
return `${monthly} €`;
}
function planBillingNote(plan, cycle) {
const monthly = plan.raw?.monthlyPriceEur ?? 0;
if (monthly === 0) return "Gratuit · sans carte bancaire";
const yearly = plan.raw?.yearlyPriceEur;
if (cycle === "yearly" && yearly) return `Facturé ${yearly} € / an via Shopify Billing`;
return `Facturé ${monthly} € / mois via Shopify Billing`;
}
function ObBackBtn({ onBack, disabled }) {
return (
← Retour
);
}
function difficultyLabel(score) {
if (score == null) return "—";
const n = score <= 1 ? score * 100 : score;
if (n <= 33) return "faible";
if (n <= 66) return "moyenne";
return "élevée";
}
function OnboardProgress({ step }) {
return (
{OB_STEPS.map((s) => (
= s.id ? " is-done" : ""}${step === s.id ? " is-current" : ""}`}>
{step > s.id ? : s.id}
{s.label}
))}
);
}
function StepAnalyse({ insights, onConfirm, initialSummary, initialProducts, quotaReached, quota, isWebsite }) {
const hasInitialSummary = !!(initialSummary && initialSummary.trim());
const [analyzing, setAnalyzing] = useOS(!hasInitialSummary);
const [summary, setSummary] = useOS(initialSummary || "");
const [analysis, setAnalysis] = useOS(null);
const [products, setProducts] = useOS(initialProducts || []);
const [productsLoading, setProductsLoading] = useOS(!isWebsite);
const [saving, setSaving] = useOS(false);
const [err, setErr] = useOS("");
const ranRef = React.useRef(false);
async function runAnalysis() {
setAnalyzing(true);
setErr("");
try {
const res = await GuyAPI.onboardingSuggestDescription();
if (res?.description) setSummary(res.description);
if (res?.analysis) setAnalysis(res.analysis);
const pages = res?.pages || [];
if (isWebsite && pages.length) {
setProducts((prev) => {
if (prev.some((p) => (p.name || "").trim() || (p.url || "").trim())) return prev;
return pages.slice(0, 8).map((p, i) => ({
name: p.name,
url: p.url,
selected: i < 4,
}));
});
}
} catch (e) {
setErr(
e.message ||
(isWebsite
? "Impossible d'analyser le site. Vous pouvez rédiger le résumé à la main."
: "Impossible d'analyser la boutique. Vous pouvez rédiger le résumé à la main."),
);
} finally {
setAnalyzing(false);
if (isWebsite) setProductsLoading(false);
}
}
useOE(() => {
if (ranRef.current) return;
ranRef.current = true;
if (!hasInitialSummary) runAnalysis();
else if (isWebsite) setProductsLoading(false);
if (isWebsite) return;
if (!(initialProducts && initialProducts.length)) {
(async () => {
setProductsLoading(true);
try {
const res = await GuyAPI.shopifyProducts();
const list = (res?.products || [])
.filter((p) => p.url)
.map((p) => ({
name: p.title,
url: p.url,
imageUrl: p.imageUrl || "",
shopifyProductId: p.id || "",
selected: true,
}));
setProducts(list);
} catch {
setProducts([]);
} finally {
setProductsLoading(false);
}
})();
} else {
setProductsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function toggleProduct(i) {
setProducts((prev) => prev.map((p, idx) => (idx === i ? { ...p, selected: !p.selected } : p)));
}
function updateManual(i, field, value) {
setProducts((prev) => prev.map((p, idx) => (idx === i ? { ...p, [field]: value } : p)));
}
function removeProduct(i) {
setProducts((prev) => prev.filter((_, idx) => idx !== i));
}
function addManualProduct() {
setProducts((prev) => [...prev, { name: "", url: "", selected: true, manual: true }]);
}
async function confirm() {
setSaving(true);
setErr("");
const selected = products
.filter((p) => p.selected && (p.name || "").trim() && /^https?:\/\//i.test((p.url || "").trim()))
.map((p) => {
const row = { name: p.name.trim(), url: p.url.trim() };
if (p.imageUrl && /^https?:\/\//i.test(p.imageUrl)) row.imageUrl = p.imageUrl;
if (p.shopifyProductId) row.shopifyProductId = p.shopifyProductId;
return row;
});
try {
await onConfirm({ summary: summary.trim(), products: selected });
} catch (e) {
setErr(e.message || "Enregistrement impossible.");
} finally {
setSaving(false);
}
}
const pageCount = analysis?.pagesFromHome;
const catalogCount =
(analysis?.productsFromCatalog || analysis?.productsFromHome) ??
insights.productCount;
const productCount = isWebsite ? pageCount : catalogCount;
const productLabel = isWebsite
? productCount != null
? `${productCount} page${productCount > 1 ? "s" : ""}`
: analyzing
? "Site en cours d'analyse"
: "Site analysé"
: productCount != null
? `${productCount} produits`
: "Catalogue analysé";
const kwLabel = `${insights.keywordCount || 0} mots-clés`;
const top = insights.topKeyword;
const selectedCount = products.filter((p) => p.selected).length;
return (
Bonjour, je suis SEO GUY 👋
{isWebsite ? "J'ai analysé votre site" : "J'ai analysé votre boutique"}
{analyzing
? isWebsite
? "J'inspecte votre site, vos pages et votre activité…"
: "J'inspecte votre site, votre catalogue et vos mots-clés…"
: "Voici ce que j'ai compris de votre activité. Vérifiez et ajustez si besoin."}
{quotaReached && (
Quota hebdomadaire atteint{quota ? ` (${quota.used}/${quota.limit})` : ""}.
{" "}Vous pouvez refaire la configuration ; le premier article ne sera pas généré à la dernière étape.
)}
{productLabel}
{isWebsite ? "repérées sur votre site" : "repérés dans votre catalogue"}
{kwLabel}
dans votre base SEO
{top && (
Meilleure opportunité
« {top.keyword} » —{" "}
{top.volume != null ? `${top.volume.toLocaleString("fr-FR")} rech./mois` : "volume à estimer"}
· Difficulté {difficultyLabel(top.difficulty)}
)}
Résumé de votre activité (rédigé par SEO GUY)
{!analyzing && (
Régénérer
)}
{analyzing ? (
SEO GUY rédige le résumé de votre {isWebsite ? "activité" : "boutique"}…
) : (
{isWebsite ? "Pages et offres à mettre en avant" : "Produits à mettre en avant"}
{selectedCount > 0 ? ` · ${selectedCount} sélectionné${selectedCount > 1 ? "s" : ""}` : ""}
{productsLoading || (isWebsite && analyzing && products.length === 0) ? (
{isWebsite ? "Lecture des pages de votre site…" : "Récupération de vos produits Shopify…"}
) : products.length === 0 ? (
{isWebsite
? "Aucune page détectée automatiquement. Ajoutez un lien (services, à propos, offre…) ci-dessous."
: "Aucun produit détecté automatiquement. Ajoutez-en manuellement ci-dessous."}
) : (
)}
{isWebsite ? "Ajouter une page (lien + nom)" : "Ajouter un produit (lien + nom)"}
{err &&
{err}
}
{saving ? (<> Enregistrement…>) : (<>Parfait, on configure >)}
);
}
function resizePersonaPhotoFile(file, size = 400, quality = 0.82) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas indisponible");
const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2;
const sy = (img.height - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, size, size);
let dataUrl = canvas.toDataURL("image/jpeg", quality);
if (dataUrl.length > 400000) dataUrl = canvas.toDataURL("image/jpeg", 0.65);
resolve(dataUrl);
} catch (err) {
reject(err);
} finally {
URL.revokeObjectURL(url);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Image illisible"));
};
img.src = url;
});
}
function PersonaCard({ persona, onChange, canRemove, onRemove, interestOptions }) {
const [regen, setRegen] = useOS(false);
const [regenErr, setRegenErr] = useOS("");
const [imgBusy, setImgBusy] = useOS(false);
const [imgErr, setImgErr] = useOS("");
const fileRef = React.useRef(null);
const interests = Array.isArray(persona.interests) ? persona.interests : [];
const interestList = interestOptions && interestOptions.length ? interestOptions : PERSONA_INTERESTS;
const avatarUrl = persona.customPhotoUrl || null;
const initials = (persona.name || "?").trim().slice(0, 2).toUpperCase();
function toggleInterest(label) {
const has = interests.includes(label);
const next = has ? interests.filter((x) => x !== label) : [...interests, label];
onChange({ ...persona, interests: next });
}
async function onPhotoFile(e) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 8_000_000) {
setImgErr("Image trop lourde (max 8 Mo).");
return;
}
setImgBusy(true);
setImgErr("");
try {
const dataUrl = await resizePersonaPhotoFile(file);
onChange({ ...persona, customPhotoUrl: dataUrl });
} catch {
setImgErr("Impossible de traiter cette image.");
} finally {
setImgBusy(false);
}
e.target.value = "";
}
async function generateAvatar() {
if (!(persona.description || "").trim()) {
setImgErr("Décrivez d'abord le persona dans « Qui est-ce ? ».");
return;
}
setImgBusy(true);
setImgErr("");
try {
const res = await GuyAPI.onboardingGeneratePersonaAvatar({
name: persona.name || "",
description: persona.description || "",
});
if (res?.url) onChange({ ...persona, customPhotoUrl: res.url });
} catch (e) {
setImgErr(e.message || "Génération impossible.");
} finally {
setImgBusy(false);
}
}
async function regenerate() {
setRegen(true);
setRegenErr("");
try {
const res = await GuyAPI.onboardingSuggestPersona({
name: persona.name || "Client",
interestOptions: interestList,
toneOptions: PERSONA_TONES.map((t) => t.label),
});
if (res?.persona) {
onChange({
...persona,
name: res.persona.name || persona.name,
description: res.persona.description || persona.description,
tone: res.persona.tone || persona.tone,
interests:
Array.isArray(res.persona.interests) && res.persona.interests.length
? res.persona.interests
: interests,
});
}
} catch (e) {
setRegenErr(e.message || "Génération impossible.");
} finally {
setRegen(false);
}
}
return (
{avatarUrl ? (
) : (
{initials}
)}
fileRef.current?.click()}>
Charger une photo
{imgBusy ? "Génération…" : "Générer 400×400"}
{avatarUrl && (
onChange({ ...persona, customPhotoUrl: null })}
>
Retirer
)}
{imgErr &&
{imgErr}
}
Persona
onChange({ ...persona, name: e.target.value })}
/>
{canRemove && (
✕
)}
Qui est-ce ?
{regen ? : }
{regen ? " Génération…" : " Regénérer avec l'IA"}
);
}
function PlanPicker({ plans, selectedCode, currentCode, cycle, onCycleChange, onSelect, couponCode, onCouponChange, isWebsite }) {
const [expanded, setExpanded] = useOS(null);
const selected = plans.find((p) => p.code === selectedCode);
const hasCoupon = !!(couponCode || "").trim();
return (
{isWebsite
? "Choisissez votre rythme de publication. Les packs payants sont facturés via Stripe (paiement sécurisé, annulable)."
: <>Choisissez votre rythme de publication. Les plans payants sont facturés via Shopify Billing (paiement sécurisé, annulable).>}
{" "}Le plan Découverte est gratuit sans carte bancaire.
onCycleChange("yearly")}>Annuel −17 %
onCycleChange("monthly")}>Mensuel
{plans.map((plan) => (
onSelect(plan.code)}
>
{plan.highlighted && Recommandé }
{currentCode === plan.code && !plan.highlighted && (
Actuel
)}
{plan.name}
{planArticlesLabel(plan)}
{hasCoupon && plan.code !== "discovery" ? "Offert" : planPriceLabel(plan, cycle)}
{!(hasCoupon && plan.code !== "discovery") && / mois }
{hasCoupon && plan.code !== "discovery"
? "Code promo — sans paiement"
: planBillingNote(plan, cycle)}
setExpanded(expanded === plan.code ? null : plan.code)}
>
{expanded === plan.code ? "Masquer le détail" : "Voir le détail"}
{expanded === plan.code && (
{(plan.features || []).map((feat) => (
{feat.label}
{feat.ok ? "✓" : "—"}
))}
)}
))}
{selected && (
Formule {selected.name} : {planArticlesLabel(selected)}
{" · "}
{hasCoupon && selected.code !== "discovery"
? "offert avec le code promo"
: planBillingNote(selected, cycle)}
)}
);
}
function StepPersonasPlan({ personas, plans, currentPlanCode, planAlreadyChosen, onPersonasChange, onNext, onSkip, onBack, isWebsite }) {
const [selectedPlan, setSelectedPlan] = useOS(currentPlanCode || "pro");
const [cycle, setCycle] = useOS("yearly");
const [couponCode, setCouponCode] = useOS("");
const [active, setActive] = useOS(0);
const [busy, setBusy] = useOS(false);
const [error, setError] = useOS("");
const [autopilot, setAutopilot] = useOS(true);
const [cpvAccepted, setCpvAccepted] = useOS(false);
const [autopilotLiabilityAccepted, setAutopilotLiabilityAccepted] = useOS(false);
const planForLimit = planAlreadyChosen ? currentPlanCode : selectedPlan;
const maxPersonas = Math.max(1, segmentLimitForPlan(planForLimit, plans));
const visiblePersonas = personas.slice(0, maxPersonas);
const activeIdx = Math.min(active, visiblePersonas.length - 1);
const activePersona = visiblePersonas[activeIdx];
function updatePersona(i, next) {
const copy = [...personas];
copy[i] = next;
onPersonasChange(copy);
}
function addPersona() {
if (personas.length >= maxPersonas) return;
const next = [
...personas,
{
id: `p${Date.now()}`,
name: "Nouveau persona",
description: "",
tone: PERSONA_TONES[0].label,
interests: [],
customPhotoUrl: null,
},
];
onPersonasChange(next);
setActive(next.length - 1);
}
function removePersona(i) {
if (personas.length <= 1) return;
const next = personas.filter((_, idx) => idx !== i);
onPersonasChange(next);
setActive((cur) => Math.max(0, Math.min(cur, next.length - 1)));
}
async function confirm() {
setError("");
if (!cpvAccepted) {
setError("Veuillez accepter les Conditions particulières de vente.");
return;
}
if (autopilot && !autopilotLiabilityAccepted) {
setError("Veuillez accepter la clause de responsabilité du pilotage automatique.");
return;
}
setBusy(true);
try {
const now = new Date().toISOString();
await GuyAPI.savePublicationSchedule({
enabled: autopilot,
timezone: "Europe/Paris",
cadence: "weekly",
biweeklyOnEvenIsoWeeks: true,
slots: defaultAutopilotSlots(planAlreadyChosen ? currentPlanCode : selectedPlan),
requireReviewBeforePublish: !autopilot,
publishWithoutReview: autopilot,
liabilityAcceptedAt: autopilot ? now : null,
cpvAcceptedAt: now,
lastProcessedSlotKey: null,
rhythmCustomized: false,
});
const targets = personasToTargets(personas).slice(0, maxPersonas);
await GuyAPI.saveConfig({ targetAudiences: targets, currentStep: 2 });
if (!planAlreadyChosen) {
const planRes = await GuyAPI.selectPlan({
planCode: selectedPlan,
cycle,
couponCode: couponCode.trim() || undefined,
});
if (planRes.redirectUrl) {
window.location.href = planRes.redirectUrl;
return;
}
}
onNext();
} catch (e) {
setError(e.message || "Erreur lors de la confirmation.");
} finally {
setBusy(false);
}
}
return (
Pour qui rédigez-vous ?
J'ai préparé {visiblePersonas.length} profil{visiblePersonas.length > 1 ? "s" : ""} pour vos articles. Validez ou ajustez — ils seront calibrés pour {isWebsite ? "vos lecteurs" : "vos acheteurs"}.
{visiblePersonas.map((p, i) => (
setActive(i)}
>
{p.customPhotoUrl ? (
) : (
{(p.name || "?").slice(0, 1).toUpperCase()}
)}
{p.name || "Persona"}
))}
{visiblePersonas.length < maxPersonas && (
Ajouter
)}
{activePersona && (
1}
onRemove={() => removePersona(activeIdx)}
onChange={(next) => updatePersona(activeIdx, next)}
interestOptions={isWebsite ? PERSONA_INTERESTS_WEBSITE : PERSONA_INTERESTS}
/>
)}
{!planAlreadyChosen && (
<>
Votre formule
>
)}
{error && {error}
}
2 / 4
{busy ? (<> Enregistrement…>) : (<>Continuer >)}
);
}
function GscConnectModal({ open, onClose, onSuccess, gscConnected }) {
const [connecting, setConnecting] = useOS(false);
const [error, setError] = useOS("");
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 ou réessayez plus tard.");
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) {
onSuccess();
onClose();
} else {
setError("Connexion Google non finalisée.");
}
}
};
window.addEventListener("message", onMessage);
} catch (e) {
setConnecting(false);
setError(e.message || "Erreur de connexion");
}
}
if (!open) return null;
return (
e.stopPropagation()}>
✕
G
Connecter Google Search Console
Une fenêtre Google s'ouvre — vous pouvez fermer cette pop-up et continuer l'onboarding même si ça échoue.
{gscConnected &&
GSC déjà connecté sur ce site.
}
{error &&
{error}
}
Fermer et continuer sans GSC
(gscConnected ? onSuccess() : connect())} disabled={connecting}>
{connecting ? (<> Connexion…>) : (gscConnected ? "Continuer" : (<> Ouvrir Google>))}
);
}
function StepGSC({ onNext, onSkip, onBack, gscConnected }) {
const [modalOpen, setModalOpen] = useOS(false);
return (
G
Google Search Console (optionnel)
Recommandé pour des mots-clés réels — vous pouvez passer cette étape.
{gscConnected && (
Google Search Console est déjà connecté.
)}
{[
{ icon: "chart", text: "Requêtes réelles qui amènent du trafic" },
{ icon: "target", text: "Mots-clés cachés à cibler" },
{ icon: "trending", text: "Suivi des positions" },
].map((b) => (
))}
Continuer sans GSC
setModalOpen(true)}>
Connecter Google
setModalOpen(false)}
onSuccess={onNext}
gscConnected={gscConnected}
/>
);
}
function StepFirstArticle({ article, loading, error, onPublish, onDashboard, onBack, quotaReached }) {
const failed = !!error && !article && !loading && !quotaReached;
return (
{quotaReached
? "Configuration terminée"
: failed
? "Le premier article n'a pas pu être généré"
: "🎉 Votre premier article est prêt !"}
{quotaReached
? "Merci d'avoir réalisé la configuration, vous avez déjà généré l'ensemble des articles pour cette semaine."
: failed
? "Vous pourrez le lancer depuis le tableau de bord une fois le point ci-dessous réglé."
: "J'ai rédigé, optimisé et illustré votre premier article. Il n'attend que vous."}
{loading && !quotaReached && (
SEO GUY rédige votre premier article…
)}
{error && !quotaReached &&
{error}
}
{quotaReached ? (
Votre prochain créneau de génération s'ouvrira au début de la semaine prochaine.
En attendant, vous pouvez ajuster vos réglages depuis le tableau de bord.
) : article ? (
Prêt à publier
{article.title}
Mot-clé {article.keyword}
{article.volume && Recherches {article.volume} }
Lecture {article.readMin || 5} min
{article.score && (
)}
{article.intro &&
{article.intro}
}
) : (
!error && (
)
)}
{quotaReached ? (
Tableau de bord
) : (
<>
Tableau de bord
Voir l'article
>
)}
);
}
function OnboardingQuitDialog({ open, busy, onCancel, onSaveQuit, onDiscardQuit }) {
if (!open) return null;
return (
e.stopPropagation()}
>
Quitter la configuration ?
Voulez-vous enregistrer vos modifications avant de quitter ?
{busy ? (<> Enregistrement…>) : "Enregistrer et quitter"}
Quitter sans enregistrer
Annuler
);
}
function WordpressConnectStep({
suggestedUrl = "",
initialUsername = "",
onConnected,
onSkip,
}) {
const [baseUrl, setBaseUrl] = useOS(suggestedUrl || "");
const [username, setUsername] = useOS(initialUsername || "");
const [appPassword, setAppPassword] = useOS("");
const [busy, setBusy] = useOS(false);
const [error, setError] = useOS("");
const [message, setMessage] = useOS("");
useOE(() => {
if (!baseUrl && suggestedUrl) setBaseUrl(suggestedUrl);
}, [suggestedUrl]);
async function run(action) {
setError("");
setMessage("");
setBusy(true);
try {
const payload = {
baseUrl: baseUrl.trim(),
username: username.trim(),
appPassword: appPassword.trim(),
};
const res = action === "test"
? await GuyAPI.testWordpressConnection(payload)
: await GuyAPI.saveWordpressConnection(payload);
const n = res?.categories?.length ?? 0;
if (action === "test") {
setMessage(n ? `Connexion OK · ${n} catégorie(s)` : "Connexion OK");
return;
}
onConnected?.(res);
} catch (e) {
setError(e.message || "Connexion WordPress impossible.");
} finally {
setBusy(false);
}
}
return (
Connectons votre WordPress
Installez le plugin, générez un mot de passe d’application, puis collez-le ici.
Guy rédigera ; WordPress publiera.
Téléchargez{" "}
assistant-guy.zip
{" "}puis Extensions → Téléverser → Activer.
Menu Assistant Guy → générer le mot de passe d’application (compte Éditeur).
Collez URL + identifiant + secret ci-dessous. Tester, puis Enregistrer.
URL du site
setBaseUrl(e.target.value)}
disabled={busy}
autoComplete="off"
/>
Identifiant WordPress
setUsername(e.target.value)}
disabled={busy}
autoComplete="username"
/>
Mot de passe d’application
setAppPassword(e.target.value)}
disabled={busy}
autoComplete="new-password"
/>
Ce n’est pas le mot de passe de connexion WordPress. Il ne s’affiche qu’une fois.
{message &&
{message}
}
{error &&
{error}
}
Plus tard — Guy hébergera les articles
Plugin
run("test")} disabled={busy || !baseUrl.trim() || !username.trim() || !appPassword.trim()}>
{busy ? "…" : "Tester"}
run("save")} disabled={busy || !baseUrl.trim() || !username.trim() || !appPassword.trim()}>
{busy ? (<> Enregistrement…>) : (<>Enregistrer >)}
);
}
function OnboardingFlow({
onComplete,
onExit,
onOpenReview,
onStepChange,
initialStep = 1,
insights,
plans,
currentPlanCode,
onboardingCompleted = false,
featuredProducts: featuredProductsSeed = [],
businessSummary: businessSummarySeed = "",
personas: personasSeed,
keywords = [],
gscConnected = false,
quota = null,
isWebsite = false,
}) {
const [step, setStep] = useOS(initialStep);
const [personas, setPersonas] = useOS(() =>
defaultPersonasFromAudiences(personasSeed || []),
);
const [businessSummary, setBusinessSummary] = useOS(businessSummarySeed || "");
const [featuredProducts, setFeaturedProducts] = useOS(featuredProductsSeed || []);
const [articlePreview, setArticlePreview] = useOS(null);
const [articleLoading, setArticleLoading] = useOS(false);
const [articleError, setArticleError] = useOS("");
const [quitOpen, setQuitOpen] = useOS(false);
const [quitBusy, setQuitBusy] = useOS(false);
const articleTriggeredRef = React.useRef(false);
const planAlreadyChosen =
!!onboardingCompleted || (!!currentPlanCode && currentPlanCode !== "discovery");
const quotaReached = !!(quota && !quota.unlimited && !quota.canGenerate);
const targetStrings = useOM(
() => personasToTargets(personas).map((t) => t.audience),
[personas],
);
function goTo(s) {
setStep(s);
onStepChange?.(s);
GuyAPI.saveConfig({ currentStep: s - 1 }).catch(() => undefined);
}
function next() { goTo(Math.min(step + 1, 4)); }
function skip() { goTo(Math.min(step + 1, 4)); }
function back() { goTo(Math.max(step - 1, 1)); }
async function confirmAnalyse({ summary, products }) {
setBusinessSummary(summary || "");
setFeaturedProducts(products || []);
const patch = { currentStep: 1 };
if (summary) patch.editorialCharter = summary;
if (Array.isArray(products)) patch.featuredProducts = products;
await GuyAPI.saveConfig(patch);
// Seed IA en arrière-plan : ~30 mots-clés générés depuis la description
// puis enrichis Ranxplorer (idempotent côté serveur). Les tops GSC s'y
// ajouteront à l'étape suivante si la GSC est connectée.
GuyAPI.onboardingSeedKeywords({ description: summary || undefined }).catch(
() => undefined,
);
next();
}
async function finish(publish) {
await GuyAPI.saveConfig({ onboardingCompleted: true, currentStep: 4 }).catch(() => undefined);
onComplete();
if (publish) onOpenReview(articlePreview || PROPOSED_QUEUE[0]);
}
async function saveAndQuit() {
setQuitBusy(true);
try {
const patch = {
currentStep: Math.max(0, step - 1),
featuredProducts: featuredProducts || [],
};
if (businessSummary) patch.editorialCharter = businessSummary;
const targets = personasToTargets(personas);
if (targets.length) patch.targetAudiences = targets;
await GuyAPI.saveConfig(patch);
setQuitOpen(false);
onExit?.();
} catch (e) {
setQuitBusy(false);
window.alert(e.message || "Enregistrement impossible.");
}
}
function discardAndQuit() {
setQuitOpen(false);
onExit?.();
}
useOE(() => {
if (step !== 4 || articleTriggeredRef.current) return;
articleTriggeredRef.current = true;
// Quota atteint : on finalise la config sans générer d'article.
if (quotaReached) {
setArticleLoading(false);
setArticlePreview(null);
setArticleError("");
return;
}
setArticleLoading(true);
setArticleError("");
(async () => {
try {
let featured = (featuredProducts || []).slice(0, 5);
if (!featured.length) {
const productsRes = await GuyAPI.shopifyProducts().catch(() => ({ products: [] }));
featured = (productsRes.products || []).slice(0, 5).map((p) => {
const row = { name: p.title, url: p.url };
if (p.imageUrl) row.imageUrl = p.imageUrl;
if (p.id) row.shopifyProductId = p.id;
return row;
});
}
// Liste fraîche depuis l'API : la connexion GSC (étape précédente)
// importe automatiquement les tops requêtes en mots-clés favoris, que
// le bootstrap chargé avant l'onboarding ne contient pas encore.
let kwSource = keywords;
try {
const fresh = await GuyAPI.keywordsList();
if (fresh.keywords?.length) kwSource = fresh.keywords;
} catch {
// On retombe sur les mots-clés du bootstrap.
}
const kws = kwSource.length
? kwSource.slice(0, 20).map((k) => ({
keyword: k.keyword,
volume: k.volume ?? 0,
favorite: true,
}))
: insights?.topKeyword
? [{ keyword: insights.topKeyword.keyword, volume: insights.topKeyword.volume ?? 0, favorite: true }]
: [{ keyword: "guide achat", volume: 0, favorite: true }];
const titlesRes = await GuyAPI.onboardingPreviewTitles({
count: 3,
keywords: kws,
targets: targetStrings.length ? targetStrings : undefined,
precautions: businessSummary
? [businessSummary, "Respecter les précautions de rédaction standard."]
: ["Respecter les précautions de rédaction standard."],
featuredProducts: featured,
});
const chosen = titlesRes.titles?.[0];
if (!chosen) throw new Error("Aucun titre généré pour ce site.");
const defer = await GuyAPI.onboardingDeferArticle({
title: chosen.title,
keywords: chosen.keywords,
persona: chosen.persona || "",
});
const article = await GuyAPI.pollArticleReady(defer.articleId);
const plain = (article.bodyHtml || "").replace(/<[^>]+>/g, " ").trim();
setArticlePreview({
id: article.id,
title: article.title || chosen.title,
keyword: chosen.keywords,
volume: "",
readMin: 5,
score: 92,
intro: plain.slice(0, 220),
bodyHtml: article.bodyHtml,
imageUrl: article.imageUrl,
raw: { id: article.id, title: article.title, bodyHtml: article.bodyHtml, imageUrl: article.imageUrl },
});
} catch (e) {
// Pas de fallback démo : afficher l'erreur réelle (quota, IA, etc.)
// plutôt qu'un faux article qui sème la confusion.
setArticleError(e.message || "Génération impossible.");
setArticlePreview(null);
} finally {
setArticleLoading(false);
}
})();
}, [step, keywords, insights, targetStrings, quotaReached]);
const wide = step === 2 && !planAlreadyChosen;
return (
setQuitOpen(true)}
aria-label="Quitter la configuration"
>
Quitter
{step === 1 && (
)}
{step === 2 && (
)}
{step === 3 &&
}
{step === 4 && (
finish(true)}
onDashboard={() => finish(false)}
/>
)}
!quitBusy && setQuitOpen(false)}
onSaveQuit={saveAndQuit}
onDiscardQuit={discardAndQuit}
/>
);
}
function OnboardingResumeBanner({ step, onResume }) {
const next = OB_RESUME_LABELS[step - 1] || OB_RESUME_LABELS[3];
return (
Votre configuration n'est pas terminée.
Prochaine étape : {next}
Reprendre
);
}
Object.assign(window, { OnboardingFlow, OnboardingResumeBanner, WordpressConnectStep });