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: () => (
), }, { 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: () => ( ), }, { 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: () => (
{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 */}
{__('LLMagnet',
{showSuccess ? ( <>

) : wizardStep === 0 ? ( <>

) : wizardStep === 1 ? ( <>

{__('Read our Privacy Policy', 'llmagnet-llm-txt-generator')}
) : ( <>

{/* Description */}

{/* Action area */} {activeStep!.completed ? (

{__("This step is complete. Let's continue.", '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 (
{/* Right: Illustration image */}
{/* Mobile progress dots */}
{Array.from({ length: WIZARD_PANEL_COUNT }, (_, i) => { const isActive = showSuccess ? i === WIZARD_PANEL_COUNT - 1 : i === wizardStep; return (
{/* Mobile image strip */}
); return createPortal(modal, document.body); }