import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle2, ExternalLink, Loader2 } from 'lucide-react';
import { useFocusTrap } from '../hooks/use-focus-trap';
import { __, sprintf } from '../lib/i18n';
import {
WIZARD_PANEL_COUNT,
FIRST_SETUP_PANEL,
LAST_SETUP_PANEL,
FINISH_PANEL,
ONBOARDING_SETUP_STEP_KEYS,
getInitialWizardStep,
getNextPanelAfterCompletedStep,
} from './onboarding/steps';
import {
trackOnboardingStepViewed,
trackOnboardingStepCompleted,
trackOnboardingSkipped,
trackOnboardingDismissed,
trackOnboardingCompleted,
trackLlmsTxtGenerated,
trackFixRobotsTxt,
setTelemetryConsent,
} from '../lib/mixpanel';
function getOnboardingImgUrl(filename: string): string {
const pluginUrl = window.llmagnetDashboardData?.pluginUrl ?? '';
return `${pluginUrl}assets/react-build/assets/onboarding/${filename}`;
}
const imgLlmsTxt = getOnboardingImgUrl('llmstxt.jpg');
const imgRobotsTxt = getOnboardingImgUrl('robotstxt.jpg');
const imgLlmVisit = getOnboardingImgUrl('firstvisit.jpg');
const imgSuccess = getOnboardingImgUrl('firstvisit.jpg');
const imgOnboardingLogo = getOnboardingImgUrl('onboarding_logo.png');
const imgPreOnboarding = getOnboardingImgUrl('preonboarding.jpg');
/** General Sans variable (Fontshare); titles use the variable cut per design */
const onboardingTitleStyle: React.CSSProperties = {
fontFamily: '"General Sans", sans-serif',
fontWeight: 500,
fontSize: '39.04px',
lineHeight: '53px',
letterSpacing: '-0.02em',
color: '#FFFFFF',
marginTop: '30%'
};
const onboardingDescriptionStyle: React.CSSProperties = {
fontFamily: '"General Sans", sans-serif',
fontWeight: 500,
fontSize: '15.32px',
lineHeight: '20.6px',
letterSpacing: '0',
color: '#FFFFFF',
};
/** Word-by-word reveal (blur → sharp, opacity 0 → 1); actions fade in after copy. */
const ONB_WORD_STAGGER_SEC = 0.055;
const ONB_WORD_DURATION_SEC = 0.52;
const ONB_WORD_BLUR_PX = 11;
const ONB_SECTION_GAP_SEC = 0.12;
const ONB_ACTION_FADE_SEC = 0.5;
const ONB_ACTION_EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
function sectionEndSec(wordCount: number, staggerSec: number, durationSec: number): number {
if (wordCount <= 0) return 0;
return (wordCount - 1) * staggerSec + durationSec;
}
/** Re-runs blur/word reveal when the visible panel changes (step navigation or success screen). */
function useOnboardingStepReveal(layoutKey: string) {
const [reveal, setReveal] = useState(false);
useEffect(() => {
setReveal(false);
let cancelled = false;
const id = requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!cancelled) setReveal(true);
});
});
return () => {
cancelled = true;
cancelAnimationFrame(id);
};
}, [layoutKey]);
return reveal;
}
interface AnimatedWordsProps {
text: string;
/** When true, words animate to visible; when false, hidden state. */
isRevealed: boolean;
/** Delay before the first word in this block (seconds). */
baseDelaySec: number;
staggerSec?: number;
durationSec?: number;
blurPx?: number;
}
function AnimatedWords({
text,
isRevealed,
baseDelaySec,
staggerSec = ONB_WORD_STAGGER_SEC,
durationSec = ONB_WORD_DURATION_SEC,
blurPx = ONB_WORD_BLUR_PX,
}: AnimatedWordsProps) {
const words = text.split(/\s+/).filter(Boolean);
return (
<>
{words.map((word, i) => (
{word}
))}
>
);
}
interface OnboardingActionsRevealProps {
isRevealed: boolean;
/** Seconds after mount/reveal when opacity starts easing in. */
delaySec: number;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
function OnboardingActionsReveal({
isRevealed,
delaySec,
children,
className,
style,
}: OnboardingActionsRevealProps) {
return (
{children}
);
}
/** Matches primary onboarding actions (Generate, Fix, Open ChatGPT, Next, etc.) */
const onboardingActionButtonClassName =
'inline-flex items-center justify-center gap-2 px-5 py-3 text-white text-sm font-medium transition-all duration-200 hover:opacity-90 disabled:opacity-50';
const onboardingActionButtonStyle: React.CSSProperties = {
background: 'linear-gradient(197deg, #311646 0%, #261136 100%)',
boxShadow:
'0 6px 18px rgba(0, 0, 0, 0.45), 0px 2px 4px rgba(0, 0, 0, 0.10), 0px 2px 4px rgba(255, 255, 255, 0.40) inset',
borderRadius: 14,
backdropFilter: 'blur(6px)',
lineHeight: '26px',
minHeight: 48,
border: 'none',
};
const onboardingSecondaryButtonStyle: React.CSSProperties = {
background: 'transparent',
boxShadow: 'none',
borderRadius: 14,
lineHeight: '26px',
minHeight: 48,
border: '1px solid rgba(255, 255, 255, 0.28)',
color: 'rgba(255, 255, 255, 0.92)',
};
interface OnboardingData {
steps: {
first_llm_visit: boolean;
llms_txt: boolean;
email_reports: boolean;
robots_txt: boolean;
};
completed: boolean;
dismissed: boolean;
step_links: {
first_llm_visit: string;
llms_txt: string;
email_reports: string;
robots_txt: string;
};
step_prompts: {
first_llm_visit_primary: string;
first_llm_visit_secondary: string;
};
site_url: string;
admin_email?: string;
}
interface OnboardingWizardProps {
data: OnboardingData;
/** First + last name from WP profile, or username */
userDisplayName: string;
onGenerateNow: () => Promise<{ success: boolean; message: string; timestamp?: string }>;
onDismiss: () => void;
onComplete: () => void;
/** Dev/playground only: force the wizard to open on a specific panel index. */
initialPanel?: number;
/** Dev/playground only: force the success screen to render. */
initialShowSuccess?: boolean;
}
const STEP_IMAGES = [imgLlmsTxt, imgRobotsTxt, imgLlmVisit];
export function OnboardingWizard({ data, userDisplayName, onGenerateNow, onDismiss, onComplete, initialPanel, initialShowSuccess }: OnboardingWizardProps) {
const [steps, setSteps] = useState(data.steps);
/** 0 = pre-welcome screen; 1 = telemetry opt-in; then index into stepDefinitions */
const [wizardStep, setWizardStep] = useState(() => initialPanel ?? getInitialWizardStep(data.steps));
/** Tracks which setup-step panels the user has actually visited in the wizard. */
const [visitedSteps, setVisitedSteps] = useState>(new Set());
const [isSavingTelemetry, setIsSavingTelemetry] = useState(false);
const [shareUsage, setShareUsage] = useState(false);
const [shareEmail, setShareEmail] = useState(false);
const [isPolling, setIsPolling] = useState(false);
const [showSuccess, setShowSuccess] = useState(initialShowSuccess ?? false);
const [isGenerating, setIsGenerating] = useState(false);
const [isFixingRobots, setIsFixingRobots] = useState(false);
const pollIntervalRef = useRef | null>(null);
const wizardDialogRef = useRef(null);
const completedCount = ONBOARDING_SETUP_STEP_KEYS.filter((key) => steps[key]).length;
const allDone = completedCount === ONBOARDING_SETUP_STEP_KEYS.length;
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const prevOverflowHtml = html.style.overflow;
const prevOverflowBody = body.style.overflow;
html.style.overflow = 'hidden';
body.style.overflow = 'hidden';
return () => {
html.style.overflow = prevOverflowHtml;
body.style.overflow = prevOverflowBody;
};
}, []);
const greetingName = userDisplayName.trim() || __('there', 'llmagnet-llm-txt-generator');
useEffect(() => {
if (showSuccess) {
trackOnboardingStepViewed('completion', WIZARD_PANEL_COUNT);
return;
}
if (wizardStep === 0) {
trackOnboardingStepViewed('pre_welcome', 0);
return;
}
if (wizardStep === 1) {
trackOnboardingStepViewed('telemetry_consent', 1);
return;
}
if (wizardStep >= FIRST_SETUP_PANEL && wizardStep <= LAST_SETUP_PANEL) {
setVisitedSteps(prev => {
if (prev.has(wizardStep)) return prev;
const next = new Set(prev);
next.add(wizardStep);
return next;
});
}
const idx = wizardStep - FIRST_SETUP_PANEL;
if (idx >= 0 && idx < ONBOARDING_SETUP_STEP_KEYS.length) {
trackOnboardingStepViewed(ONBOARDING_SETUP_STEP_KEYS[idx], wizardStep);
}
}, [wizardStep, showSuccess]);
const fetchStatus = useCallback(async () => {
try {
const apiRoot = window.wpApiSettings?.root || '/wp-json/';
const nonce = window.wpApiSettings?.nonce || '';
const res = await fetch(`${apiRoot}llm-analytics/v1/onboarding/status`, {
headers: { 'X-WP-Nonce': nonce },
credentials: 'same-origin',
});
const json = await res.json();
if (json.steps) {
setSteps(json.steps);
}
} catch (e) {
console.error('Error fetching onboarding status:', e);
}
}, []);
const markStepComplete = useCallback(async (step: keyof OnboardingData['steps']) => {
try {
const apiRoot = window.wpApiSettings?.root || '/wp-json/';
const nonce = window.wpApiSettings?.nonce || '';
const res = await fetch(`${apiRoot}llm-analytics/v1/onboarding/mark-step`, {
method: 'POST',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ step }),
});
const json = await res.json();
if (json.success && json.steps) {
setSteps(json.steps);
}
} catch (e) {
console.error('Error marking step complete:', e);
}
}, []);
useEffect(() => {
return () => stopPolling();
}, []);
useEffect(() => {
if (steps.first_llm_visit) {
stopPolling();
}
}, [steps.first_llm_visit]);
const startPolling = () => {
if (pollIntervalRef.current) return;
setIsPolling(true);
pollIntervalRef.current = setInterval(fetchStatus, 8000);
};
const stopPolling = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsPolling(false);
};
const markComplete = async () => {
try {
const apiRoot = window.wpApiSettings?.root || '/wp-json/';
const nonce = window.wpApiSettings?.nonce || '';
await fetch(`${apiRoot}llm-analytics/v1/onboarding/complete`, {
method: 'POST',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
credentials: 'same-origin',
});
} catch (e) {
console.error('Error marking onboarding complete:', e);
}
};
const handleDismiss = async () => {
try {
const apiRoot = window.wpApiSettings?.root || '/wp-json/';
const nonce = window.wpApiSettings?.nonce || '';
await fetch(`${apiRoot}llm-analytics/v1/onboarding/dismiss`, {
method: 'POST',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
credentials: 'same-origin',
});
} catch (e) {
console.error('Error dismissing onboarding:', e);
}
trackOnboardingDismissed(completedCount);
onDismiss();
};
// Focus trap + focus restore + Escape to dismiss (P2-3.2)
useFocusTrap(wizardDialogRef, { active: true, onEscape: () => void handleDismiss() });
const handleChatGPTClick = () => {
window.open(data.step_links.first_llm_visit, '_blank');
startPolling();
};
const handleSkipFirstVisit = () => {
stopPolling();
trackOnboardingSkipped('first_llm_visit');
void finishOnboarding();
};
/** Finish the wizard: persist completion, then show success (all done) or close. */
const finishOnboarding = async () => {
stopPolling();
trackOnboardingCompleted();
await markComplete();
if (allDone) {
setShowSuccess(true);
} else {
onComplete();
}
};
const handleGenerateLlms = async () => {
setIsGenerating(true);
try {
const result = await onGenerateNow();
trackLlmsTxtGenerated(result.success, 'onboarding', result.message);
if (result.success) {
trackOnboardingStepCompleted('llms_txt', 1);
await markStepComplete('llms_txt');
}
} finally {
setIsGenerating(false);
}
};
const handleFixRobots = async () => {
setIsFixingRobots(true);
try {
const apiRoot = window.wpApiSettings?.root || '/wp-json/';
const nonce = window.wpApiSettings?.nonce || '';
const res = await fetch(`${apiRoot}llm-analytics/v1/robots-inject`, {
method: 'POST',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
credentials: 'same-origin',
});
const json = await res.json();
trackFixRobotsTxt(!!json.success);
if (json.success) {
trackOnboardingStepCompleted('robots_txt', 2);
await markStepComplete('robots_txt');
}
} catch (e) {
console.error('Error fixing robots.txt:', e);
trackFixRobotsTxt(false);
} finally {
setIsFixingRobots(false);
}
};
const handleGoToDashboard = () => {
onComplete();
};
const handlePreWelcomeContinue = () => {
trackOnboardingStepCompleted('pre_welcome', 0);
setWizardStep(1);
};
/**
* Persist the two separate consents and advance. Both default off.
*/
const handlePrivacyChoice = async () => {
setIsSavingTelemetry(true);
try {
const apiRoot = window.wpApiSettings?.root || '/wp-json/';
const nonce = window.wpApiSettings?.nonce || '';
await fetch(`${apiRoot}llm-analytics/v1/privacy/settings`, {
method: 'POST',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
telemetry_enabled: shareUsage,
email_enabled: shareEmail,
}),
});
setTelemetryConsent(shareUsage);
if (shareUsage || shareEmail) {
trackOnboardingStepCompleted('telemetry_consent', 1);
}
} catch (e) {
console.error('Error saving privacy preference:', e);
} finally {
setIsSavingTelemetry(false);
setWizardStep(FIRST_SETUP_PANEL);
}
};
/** After a step is done, user confirms with Next instead of auto-advancing or jumping to the final screen. */
const handleNextFromCompletedStep = () => {
const next = getNextPanelAfterCompletedStep(wizardStep, steps);
if (next === FINISH_PANEL) {
void finishOnboarding();
return;
}
if (next === wizardStep) {
return;
}
setWizardStep(next);
};
const stepDefinitions = [
{
key: 'llms_txt' as const,
title: __('Generate your llms.txt', 'llmagnet-llm-txt-generator'),
description: __('Your llms.txt file helps AI assistants understand and reference your site content. Generate it with one click.', 'llmagnet-llm-txt-generator'),
completed: steps.llms_txt,
renderAction: () => (
{isGenerating ? <> {__('Generating...', 'llmagnet-llm-txt-generator')}> : __('Generate Now', 'llmagnet-llm-txt-generator')}
),
},
{
key: 'robots_txt' as const,
title: __('Add to robots.txt', 'llmagnet-llm-txt-generator'),
description: __('Reference your llms.txt in robots.txt so AI crawlers can automatically discover and read it.', 'llmagnet-llm-txt-generator'),
completed: steps.robots_txt,
renderAction: () => (
{isFixingRobots ? <> {__('Fixing...', 'llmagnet-llm-txt-generator')}> : __('Fix Now', 'llmagnet-llm-txt-generator')}
),
},
{
key: 'first_llm_visit' as const,
title: __('Check for first LLM visit', 'llmagnet-llm-txt-generator'),
description: __('Test your setup by asking ChatGPT about your site. This verifies everything is working correctly.', 'llmagnet-llm-txt-generator'),
completed: steps.first_llm_visit,
renderAction: () => (
{__('Test on ChatGPT', 'llmagnet-llm-txt-generator')}
{__('Skip for now', 'llmagnet-llm-txt-generator')}
{isPolling && !steps.first_llm_visit && (
{__('Waiting for your first LLM visit...', 'llmagnet-llm-txt-generator')}
{__('Skip', 'llmagnet-llm-txt-generator')}
)}
),
},
];
const setupStepIndex =
wizardStep >= FIRST_SETUP_PANEL ? wizardStep - FIRST_SETUP_PANEL : 0;
const activeStep =
wizardStep < FIRST_SETUP_PANEL ? null : stepDefinitions[setupStepIndex];
const stepImage = showSuccess
? imgSuccess
: wizardStep < FIRST_SETUP_PANEL
? imgPreOnboarding
: STEP_IMAGES[setupStepIndex];
const layoutRevealKey = showSuccess
? 'success'
: wizardStep === 0
? 'pre'
: wizardStep === 1
? 'telemetry'
: stepDefinitions[setupStepIndex].key;
const reveal = useOnboardingStepReveal(layoutRevealKey);
// --- Welcome step (wizardStep === 0) timing ---
/* translators: %s: user display name. */
const _wLine1 = sprintf(__('Hi %s', 'llmagnet-llm-txt-generator'), greetingName);
const _wLine1End = sectionEndSec(_wLine1.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _wLine2Start = _wLine1End + ONB_SECTION_GAP_SEC;
const _wLine2End = _wLine2Start + sectionEndSec(3, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC); // "Welcome to LLMagnet"
const _wDescText = __("We're thrilled to have you here. LLMagnet helps AI assistants discover your WordPress site through llms.txt, bot analytics, and an agent-ready setup. Let's get you started.", 'llmagnet-llm-txt-generator');
const _wDescStart = _wLine2End + ONB_SECTION_GAP_SEC;
const _wDescEnd = _wDescStart + sectionEndSec(_wDescText.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _wBtnStart = _wDescEnd + ONB_SECTION_GAP_SEC;
// --- Telemetry consent step (wizardStep === 1) timing ---
const adminEmail = data.admin_email || '';
const _tTitleText = __('Help improve LLMagnet', 'llmagnet-llm-txt-generator');
const _tDescText = __(
'Two optional choices. Usage data is dashboard events only. Emails go to the site admin address. Nothing is collected from your visitors.',
'llmagnet-llm-txt-generator'
);
const _tTitleEnd = sectionEndSec(_tTitleText.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _tDescStart = _tTitleEnd + ONB_SECTION_GAP_SEC;
const _tDescEnd = _tDescStart + sectionEndSec(_tDescText.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _tBtnStart = _tDescEnd + ONB_SECTION_GAP_SEC;
// --- Success screen timing ---
const _sDescText = __('Your AI visibility is live. LLMagnet is now working to get your site discovered by AI assistants.', 'llmagnet-llm-txt-generator');
const _sTitleEnd = sectionEndSec(3, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC); // "You're all set!"
const _sDescStart = _sTitleEnd + ONB_SECTION_GAP_SEC;
const _sDescEnd = _sDescStart + sectionEndSec(_sDescText.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _sBtnStart = _sDescEnd + ONB_SECTION_GAP_SEC;
// --- Active setup step timing ---
const _stepTitleText = activeStep?.title ?? '';
const _stepTitleEnd = sectionEndSec(_stepTitleText.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _stepDescText = activeStep?.description ?? '';
const _stepDescStart = _stepTitleEnd + ONB_SECTION_GAP_SEC;
const _stepDescEnd = _stepDescStart + sectionEndSec(_stepDescText.split(/\s+/).filter(Boolean).length, ONB_WORD_STAGGER_SEC, ONB_WORD_DURATION_SEC);
const _stepActionStart = _stepDescEnd + ONB_SECTION_GAP_SEC;
const modal = (
e.stopPropagation()}
>
{/* Left: Content */}
{/* Main content */}
{/* Step label */}
{showSuccess ? (
<>
{__('Go to Dashboard', 'llmagnet-llm-txt-generator')}
>
) : wizardStep === 0 ? (
<>
{__('Get Started', 'llmagnet-llm-txt-generator')}
>
) : wizardStep === 1 ? (
<>
{__('Read our Privacy Policy', 'llmagnet-llm-txt-generator')}
setShareUsage(e.target.checked)}
className="mt-1"
/>
{__('Usage data', 'llmagnet-llm-txt-generator')}
{__('Send plugin dashboard events to Mixpanel so we can improve LLMagnet. We do not send your email or site URL.', 'llmagnet-llm-txt-generator')}
setShareEmail(e.target.checked)}
className="mt-1"
/>
{adminEmail
? sprintf(__('Emails to %s', 'llmagnet-llm-txt-generator'), adminEmail)
: __('Product emails', 'llmagnet-llm-txt-generator')}
{__('Setup and first-bot-visit emails. You can turn this off later under System Information.', 'llmagnet-llm-txt-generator')}
void handlePrivacyChoice()}
disabled={isSavingTelemetry}
className={onboardingActionButtonClassName}
style={onboardingActionButtonStyle}
>
{isSavingTelemetry ? <> {__('Saving...', 'llmagnet-llm-txt-generator')}> : __('Continue', 'llmagnet-llm-txt-generator')}
>
) : (
<>
{/* Description */}
{/* Action area */}
{activeStep!.completed ? (
{__("This step is complete. Let's continue.", 'llmagnet-llm-txt-generator')}
{__('Next', 'llmagnet-llm-txt-generator')}
) : (
{activeStep!.renderAction()}
)}
>
)}
{/* Footer: progress + navigation */}
{/* Progress dots */}
{Array.from({ length: WIZARD_PANEL_COUNT }, (_, i) => {
const isPre = i === 0;
const isTelemetry = i === 1;
const stepDone = showSuccess
? true
: isPre
? wizardStep > 0
: isTelemetry
? wizardStep > 1
: visitedSteps.has(i) && stepDefinitions[i - FIRST_SETUP_PANEL].completed;
const isActive = showSuccess ? i === WIZARD_PANEL_COUNT - 1 : i === wizardStep;
return (
{
if (!showSuccess) setWizardStep(i);
}}
className="rounded-full transition-all duration-300"
style={{
width: isActive ? '35px' : '12px',
height: '12px',
background: stepDone ? '#D889FA' : isActive ? '#D889FA' : 'rgba(255,255,255,0.15)',
cursor: showSuccess ? 'default' : 'pointer',
border: 'none',
}}
/>
);
})}
{/* Right: Illustration image */}
{/* Mobile progress dots */}
{Array.from({ length: WIZARD_PANEL_COUNT }, (_, i) => {
const isActive = showSuccess ? i === WIZARD_PANEL_COUNT - 1 : i === wizardStep;
return (
{
if (!showSuccess) setWizardStep(i);
}}
className="rounded-full transition-all duration-300"
style={{
width: isActive ? '24px' : '8px',
height: '8px',
background: isActive ? '#E91E8C' : 'rgba(255,255,255,0.3)',
cursor: showSuccess ? 'default' : 'pointer',
}}
/>
);
})}
{/* Mobile image strip */}
);
return createPortal(modal, document.body);
}