PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.0.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / src / metabox / components / MetaboxApp.js

MetaboxApp.js in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.0.0, at src/metabox/components/MetaboxApp.js

664 lines 26.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * ThinkRank Metabox React Component
3 *
4 * Main React component for the SEO metabox with tabbed interface.
5 * Phase 1: Basic tab structure with SEO Analysis as default tab.
6 *
7 * @package ThinkRank
8 * @since 1.0.0
9 */
10
11 import { useState, useEffect, useRef, memo, useCallback, useMemo } from '@wordpress/element';
12 import { __ } from '@wordpress/i18n';
13 import { TabPanel } from '@wordpress/components';
14 import apiFetch from '@wordpress/api-fetch';
15
16 // Import child components
17 import SerpPreview from './SerpPreview';
18 import MetaboxSchemaTab from './MetaboxSchemaTab';
19 import SocialTab from './SocialTab';
20 import ErrorBoundary from './ErrorBoundary';
21
22 // Import the enhanced SEO Score Calculator components
23 import SEOScoreCalculator from '../../admin/components/seo/SEOScoreCalculator';
24
25 /**
26 * Main Metabox Component
27 */
28 const MetaboxApp = ({ postId, existingMetadata = {}, contentPreview = '', strings = {}, postTitle = '', homeUrl = '' }) => {
29 // Form state - preserving exact field names from jQuery version
30 const [formData, setFormData] = useState({
31 thinkrank_seo_title: existingMetadata.title || '',
32 thinkrank_meta_description: existingMetadata.description || '',
33 thinkrank_focus_keyword: existingMetadata.focus_keyword || '',
34 thinkrank_content_type: 'blog_post',
35 thinkrank_tone: 'professional'
36 });
37
38 // UI state
39 const [isGenerating, setIsGenerating] = useState(false);
40 const [isAnalyzing, setIsAnalyzing] = useState(false);
41 const [showAnalysisResults, setShowAnalysisResults] = useState(false);
42 const [showScoreBreakdown, setShowScoreBreakdown] = useState(false);
43 const [notices, setNotices] = useState([]);
44 const [analysisData, setAnalysisData] = useState(null);
45 const [lastSaved, setLastSaved] = useState(null);
46
47 // Simple cache for API responses
48 const apiCache = useRef(new Map());
49
50 // Character counters
51 const [titleCount, setTitleCount] = useState(0);
52 const [descriptionCount, setDescriptionCount] = useState(0);
53
54 // Refs for accessing DOM elements (needed for content extraction)
55 const contentPreviewRef = useRef(null);
56
57 // Tab configuration - KISS: Simple tab structure
58 const tabs = [
59 {
60 name: 'seo-analysis',
61 title: __('SEO Analysis', 'thinkrank'),
62 className: 'metabox-tab-seo-analysis'
63 },
64 {
65 name: 'schema',
66 title: __('Schema', 'thinkrank'),
67 className: 'metabox-tab-schema'
68 },
69 {
70 name: 'social',
71 title: __('Social', 'thinkrank'),
72 className: 'metabox-tab-social'
73 }
74 ];
75
76 // Initialize character counters
77 useEffect(() => {
78 setTitleCount(formData.thinkrank_seo_title.length);
79 setDescriptionCount(formData.thinkrank_meta_description.length);
80 }, [formData.thinkrank_seo_title, formData.thinkrank_meta_description]);
81
82 /**
83 * Handle form field changes
84 */
85 const handleFieldChange = (fieldName, value) => {
86 setFormData(prev => ({
87 ...prev,
88 [fieldName]: value
89 }));
90
91 // Update character counters
92 if (fieldName === 'thinkrank_seo_title') {
93 setTitleCount(value.length);
94 } else if (fieldName === 'thinkrank_meta_description') {
95 setDescriptionCount(value.length);
96 }
97 };
98
99 /**
100 * Show success notice
101 */
102 const showSuccess = (message) => {
103 const notice = {
104 id: Date.now(),
105 type: 'success',
106 message: message
107 };
108 setNotices(prev => [...prev, notice]);
109
110 // Auto-remove after 5 seconds
111 setTimeout(() => {
112 setNotices(prev => prev.filter(n => n.id !== notice.id));
113 }, 5000);
114 };
115
116 /**
117 * Show error notice
118 */
119 const showError = (message) => {
120 const notice = {
121 id: Date.now(),
122 type: 'error',
123 message: message
124 };
125 setNotices(prev => [...prev, notice]);
126
127 // Auto-remove after 8 seconds
128 setTimeout(() => {
129 setNotices(prev => prev.filter(n => n.id !== notice.id));
130 }, 8000);
131 };
132
133 /**
134 * Extract content from WordPress editor (same method as schema tab)
135 */
136 const getPostContent = () => {
137 let content = '';
138 let htmlContent = '';
139
140 // Try to get content from Block Editor first
141 if (typeof wp !== 'undefined' && wp.data && wp.data.select('core/editor')) {
142 try {
143 const blockContent = wp.data.select('core/editor').getEditedPostContent();
144 if (blockContent) {
145 htmlContent = blockContent;
146 }
147 } catch (e) {
148 // Block editor not available, try classic editor
149 }
150 }
151
152 // Fallback to TinyMCE (Classic Editor)
153 if (!htmlContent && typeof tinymce !== 'undefined') {
154 const editor = tinymce.get('content');
155 if (editor && !editor.isHidden()) {
156 htmlContent = editor.getContent();
157 }
158 }
159
160 // Fallback to textarea
161 if (!htmlContent) {
162 const contentTextarea = document.getElementById('content');
163 if (contentTextarea) {
164 htmlContent = contentTextarea.value || '';
165 }
166 }
167
168 // Convert HTML to plain text for analysis
169 if (htmlContent) {
170 const tempDiv = document.createElement('div');
171 tempDiv.innerHTML = htmlContent;
172 content = tempDiv.textContent || tempDiv.innerText || '';
173 }
174
175 // Fallback to contentPreview if no editor content found
176 if (!content && contentPreview) {
177 content = contentPreview;
178 }
179
180 // Final fallback: try to get post title if no content
181 if (!content) {
182 const titleField = document.getElementById('title');
183 if (titleField && titleField.value) {
184 content = titleField.value;
185 }
186 }
187
188 return content.trim();
189 };
190
191 /**
192 * Handle AI generation
193 */
194 const handleGenerateAI = useCallback(async () => {
195 // Extract current post content
196 const content = getPostContent();
197
198 if (!content) {
199 showError('No content found to analyze. Please add some content to your post and try again.');
200 return;
201 }
202
203 // Minimum content length check
204 if (content.length < 50) {
205 showError('Content is too short for AI analysis. Please add more content (at least 50 characters).');
206 return;
207 }
208
209 setIsGenerating(true);
210
211 try {
212 const response = await apiFetch({
213 path: '/thinkrank/v1/ai/generate-metadata',
214 method: 'POST',
215 data: {
216 content: content,
217 target_keyword: formData.thinkrank_focus_keyword,
218 content_type: formData.thinkrank_content_type,
219 tone: formData.thinkrank_tone
220 }
221 });
222
223 if (response.success) {
224 // Update form data with AI-generated content
225 setFormData(prev => ({
226 ...prev,
227 thinkrank_seo_title: response.data.title || prev.thinkrank_seo_title,
228 thinkrank_meta_description: response.data.description || prev.thinkrank_meta_description,
229 thinkrank_focus_keyword: response.data.focus_keyword || prev.thinkrank_focus_keyword
230 }));
231
232 showSuccess('AI metadata generated successfully!');
233 } else {
234 showError(response.message || 'Failed to generate AI metadata');
235 }
236 } catch (error) {
237 showError('Failed to generate AI metadata');
238 } finally {
239 setIsGenerating(false);
240 }
241 }, [formData.thinkrank_focus_keyword, formData.thinkrank_content_type, formData.thinkrank_tone, showError, showSuccess]);
242
243 /**
244 * Auto-save form data to local storage
245 */
246 const autoSave = useCallback(() => {
247 if (!postId) return;
248
249 try {
250 const saveData = {
251 formData,
252 timestamp: Date.now(),
253 postId
254 };
255 localStorage.setItem(`thinkrank_metabox_${postId}`, JSON.stringify(saveData));
256 setLastSaved(new Date());
257 } catch (error) {
258 // Local storage failed - not critical
259 }
260 }, [formData, postId]);
261
262 /**
263 * Load saved data from local storage
264 */
265 const loadSavedData = useCallback(() => {
266 if (!postId) return;
267
268 try {
269 const saved = localStorage.getItem(`thinkrank_metabox_${postId}`);
270 if (saved) {
271 const saveData = JSON.parse(saved);
272 // Only load if data is less than 24 hours old
273 if (Date.now() - saveData.timestamp < 24 * 60 * 60 * 1000) {
274 setFormData(prev => ({ ...prev, ...saveData.formData }));
275 setLastSaved(new Date(saveData.timestamp));
276 }
277 }
278 } catch (error) {
279 // Failed to load - not critical
280 }
281 }, [postId]);
282
283 /**
284 * Simple API cache helper
285 */
286 const getCachedResponse = useCallback((cacheKey) => {
287 const cached = apiCache.current.get(cacheKey);
288 if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { // 5 minutes
289 return cached.data;
290 }
291 return null;
292 }, []);
293
294 const setCachedResponse = useCallback((cacheKey, data) => {
295 apiCache.current.set(cacheKey, {
296 data,
297 timestamp: Date.now()
298 });
299 }, []);
300
301 /**
302 * Handle content analysis
303 */
304 const handleAnalyzeContent = useCallback(async () => {
305 if (!postId) {
306 showError('Post ID is required for content analysis');
307 return;
308 }
309
310 // Check cache first
311 const cacheKey = `seo-analysis-${postId}-${formData.thinkrank_focus_keyword}`;
312 const cachedResponse = getCachedResponse(cacheKey);
313
314 if (cachedResponse) {
315 setAnalysisData(cachedResponse);
316 setShowAnalysisResults(true);
317 if (cachedResponse.score_breakdown) {
318 setShowScoreBreakdown(true);
319 }
320 showSuccess('SEO analysis loaded from cache!');
321 return;
322 }
323
324 setIsAnalyzing(true);
325
326 try {
327 const response = await apiFetch({
328 path: '/thinkrank/v1/seo-score/calculate',
329 method: 'POST',
330 data: {
331 post_id: postId,
332 target_keyword: formData.thinkrank_focus_keyword,
333 save_score: true
334 }
335 });
336
337 if (response.success) {
338 // Cache the response
339 setCachedResponse(cacheKey, response.data);
340
341 setAnalysisData(response.data);
342 setShowAnalysisResults(true);
343 if (response.data.score_breakdown) {
344 setShowScoreBreakdown(true);
345 }
346 showSuccess('SEO analysis completed successfully!');
347 } else {
348 showError(response.message || 'Failed to analyze content');
349 }
350 } catch (error) {
351 showError('Failed to calculate SEO score');
352 } finally {
353 setIsAnalyzing(false);
354 }
355 }, [postId, formData.thinkrank_focus_keyword, getCachedResponse, setCachedResponse, showError, showSuccess]);
356
357 /**
358 * Update SEO score display
359 */
360 const updateSEOScore = (score) => {
361 // This will be handled in the analysis results component
362 if (analysisData) {
363 setAnalysisData(prev => ({
364 ...prev,
365 overall_score: score
366 }));
367 }
368 };
369
370 // Load saved data on component mount
371 useEffect(() => {
372 loadSavedData();
373 }, [loadSavedData]);
374
375 // Auto-save form data when it changes (debounced)
376 useEffect(() => {
377 const timer = setTimeout(() => {
378 autoSave();
379 }, 2000); // Save 2 seconds after last change
380
381 return () => clearTimeout(timer);
382 }, [formData, autoSave]);
383
384 // Save data before page unload
385 useEffect(() => {
386 const handleBeforeUnload = () => {
387 autoSave();
388 };
389
390 window.addEventListener('beforeunload', handleBeforeUnload);
391 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
392 }, [autoSave]);
393
394 /**
395 * Render SEO Analysis Tab Content - DRY: Extract existing functionality
396 */
397 const renderSEOAnalysisTab = () => (
398 <div className="thinkrank-metabox-seo-tab">
399 {/* SEO Metadata Form Card */}
400 <div className="thinkrank-card">
401 <div className="thinkrank-card-header">
402 <h4>{__('SEO Metadata', 'thinkrank')}</h4>
403 <p className="description">
404 {__('Optimize your content for search engines with AI-generated metadata.', 'thinkrank')}
405 </p>
406 </div>
407 <div className="thinkrank-card-body">
408 {/* SEO Title Field */}
409 <div className="thinkrank-field">
410 <label htmlFor="thinkrank_seo_title">
411 {__('SEO Title', 'thinkrank')}
412 <span className={`character-counter ${titleCount > 60 ? 'over-limit' : ''}`} id="title-counter">
413 {titleCount}/60
414 </span>
415 </label>
416 <input
417 type="text"
418 id="thinkrank_seo_title"
419 name="thinkrank_seo_title"
420 value={formData.thinkrank_seo_title}
421 onChange={(e) => handleFieldChange('thinkrank_seo_title', e.target.value)}
422 maxLength="60"
423 className="widefat"
424 placeholder={__('Enter SEO title or generate with AI', 'thinkrank')}
425 />
426 <p className="description">
427 {__('The title that appears in search engine results. Keep it under 60 characters.', 'thinkrank')}
428 </p>
429 </div>
430
431 {/* Meta Description Field */}
432 <div className="thinkrank-field">
433 <label htmlFor="thinkrank_meta_description">
434 {__('Meta Description', 'thinkrank')}
435 <span className={`character-counter ${descriptionCount > 160 ? 'over-limit' : ''}`} id="description-counter">
436 {descriptionCount}/160
437 </span>
438 </label>
439 <textarea
440 id="thinkrank_meta_description"
441 name="thinkrank_meta_description"
442 rows="3"
443 maxLength="160"
444 className="widefat"
445 value={formData.thinkrank_meta_description}
446 onChange={(e) => handleFieldChange('thinkrank_meta_description', e.target.value)}
447 placeholder={__('Enter meta description or generate with AI', 'thinkrank')}
448 />
449 <p className="description">
450 {__('A brief description that appears in search results. Keep it under 160 characters.', 'thinkrank')}
451 </p>
452 </div>
453
454 {/* Focus Keyword Field */}
455 <div className="thinkrank-field">
456 <label htmlFor="thinkrank_focus_keyword">
457 {__('Focus Keyword', 'thinkrank')}
458 </label>
459 <input
460 type="text"
461 id="thinkrank_focus_keyword"
462 name="thinkrank_focus_keyword"
463 value={formData.thinkrank_focus_keyword}
464 onChange={(e) => handleFieldChange('thinkrank_focus_keyword', e.target.value)}
465 className="widefat"
466 placeholder={__('e.g., WordPress SEO', 'thinkrank')}
467 />
468 <p className="description">
469 {__('The main keyword you want this content to rank for.', 'thinkrank')}
470 </p>
471 </div>
472
473 {/* AI Generation Controls */}
474 <div className="thinkrank-ai-controls">
475 <div className="ai-controls-row">
476 <div className="ai-control-group">
477 <label htmlFor="thinkrank_content_type">{__('Content Type', 'thinkrank')}</label>
478 <select
479 id="thinkrank_content_type"
480 name="thinkrank_content_type"
481 value={formData.thinkrank_content_type}
482 onChange={(e) => handleFieldChange('thinkrank_content_type', e.target.value)}
483 >
484 <option value="blog_post">{__('Blog Post', 'thinkrank')}</option>
485 <option value="page">{__('Page', 'thinkrank')}</option>
486 <option value="product">{__('Product', 'thinkrank')}</option>
487 <option value="service">{__('Service', 'thinkrank')}</option>
488 </select>
489 </div>
490
491 <div className="ai-control-group">
492 <label htmlFor="thinkrank_tone">{__('Tone', 'thinkrank')}</label>
493 <select
494 id="thinkrank_tone"
495 name="thinkrank_tone"
496 value={formData.thinkrank_tone}
497 onChange={(e) => handleFieldChange('thinkrank_tone', e.target.value)}
498 >
499 <option value="professional">{__('Professional', 'thinkrank')}</option>
500 <option value="friendly">{__('Friendly', 'thinkrank')}</option>
501 <option value="authoritative">{__('Authoritative', 'thinkrank')}</option>
502 <option value="conversational">{__('Conversational', 'thinkrank')}</option>
503 </select>
504 </div>
505 </div>
506
507 <div className="ai-buttons">
508 <button
509 type="button"
510 id="thinkrank-generate-ai"
511 className="button button-primary"
512 onClick={handleGenerateAI}
513 disabled={isGenerating}
514 >
515 <span className={`dashicons ${isGenerating ? 'dashicons-update spin' : 'dashicons-admin-generic'}`}></span>
516 {isGenerating ? (strings.generating || 'Generating...') : __('Generate with AI', 'thinkrank')}
517 </button>
518
519 <button
520 type="button"
521 id="thinkrank-analyze-content"
522 className="button"
523 onClick={handleAnalyzeContent}
524 disabled={isAnalyzing}
525 >
526 <span className={`dashicons ${isAnalyzing ? 'dashicons-update spin' : 'dashicons-search'}`}></span>
527 {isAnalyzing ? (strings.analyzing || 'Analyzing...') : __('Analyze Content', 'thinkrank')}
528 </button>
529 </div>
530 </div>
531 </div>
532 </div>
533
534 {/* SEO Analysis Results */}
535 <SEOScoreCalculator
536 postId={postId}
537 targetKeyword={formData.thinkrank_focus_keyword}
538 postTitle={postTitle}
539 metaDescription={formData.thinkrank_meta_description}
540 scoreData={showAnalysisResults && analysisData ? analysisData : null}
541 onRefresh={() => {
542 handleAnalyzeContent();
543 }}
544 />
545
546 {/* SERP Preview */}
547 <SerpPreview
548 title={formData.thinkrank_seo_title}
549 description={formData.thinkrank_meta_description}
550 postTitle={postTitle}
551 homeUrl={homeUrl}
552 />
553 </div>
554 );
555
556 return (
557 <div id="thinkrank-metabox-container" className="thinkrank-metabox">
558 <div className="thinkrank-metabox-header">
559 <h4>{__('AI-Powered SEO Optimization', 'thinkrank')}</h4>
560 <p className="description">
561 {__('Generate optimized SEO metadata using AI analysis of your content.', 'thinkrank')}
562 </p>
563 </div>
564
565 <div className="thinkrank-metabox-content">
566 {/* Notices */}
567 {notices.map(notice => (
568 <div key={notice.id} className={`notice notice-${notice.type} is-dismissible`}>
569 <p>{notice.message}</p>
570 </div>
571 ))}
572
573 {/* Tabbed Interface - KISS: Simple tab structure */}
574 <TabPanel
575 className="thinkrank-metabox-tabs"
576 activeClass="is-active"
577 tabs={tabs}
578 initialTabName="seo-analysis"
579 >
580 {(tab) => {
581 switch (tab.name) {
582 case 'seo-analysis':
583 return (
584 <ErrorBoundary fallbackMessage={__('SEO Analysis tab encountered an error.', 'thinkrank')}>
585 {renderSEOAnalysisTab()}
586 </ErrorBoundary>
587 );
588 case 'schema':
589 return (
590 <ErrorBoundary fallbackMessage={__('Schema tab encountered an error.', 'thinkrank')}>
591 <MetaboxSchemaTab
592 postId={postId}
593 postTitle={postTitle}
594 contentPreview={contentPreview}
595 formData={formData}
596 showNotice={(message, type = 'info') => {
597 if (type === 'success') {
598 showSuccess(message);
599 } else if (type === 'error') {
600 showError(message);
601 }
602 }}
603 />
604 </ErrorBoundary>
605 );
606 case 'social':
607 return (
608 <ErrorBoundary fallbackMessage={__('Social Media tab encountered an error.', 'thinkrank')}>
609 <SocialTab
610 postId={postId}
611 postTitle={postTitle}
612 formData={formData}
613 showNotice={(message, type = 'info') => {
614 if (type === 'success') {
615 showSuccess(message);
616 } else if (type === 'error') {
617 showError(message);
618 }
619 }}
620 />
621 </ErrorBoundary>
622 );
623 default:
624 return (
625 <ErrorBoundary fallbackMessage={__('SEO Analysis tab encountered an error.', 'thinkrank')}>
626 {renderSEOAnalysisTab()}
627 </ErrorBoundary>
628 );
629 }
630 }}
631 </TabPanel>
632
633 {/* Loading State */}
634 {(isGenerating || isAnalyzing) && (
635 <div className="thinkrank-loading" id="thinkrank-loading">
636 <span className="spinner is-active"></span>
637 <p>
638 {isGenerating
639 ? (strings.generating || __('Generating SEO metadata with AI...', 'thinkrank'))
640 : (strings.analyzing || __('Analyzing content...', 'thinkrank'))
641 }
642 </p>
643 </div>
644 )}
645
646 {/* Hidden fields for metadata */}
647 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value={analysisData?.overall_score || '0'} />
648 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value={existingMetadata.generated_at || ''} />
649
650 {/* Content preview for AI (hidden) */}
651 <textarea
652 ref={contentPreviewRef}
653 id="thinkrank_content_preview"
654 style={{ display: 'none' }}
655 defaultValue={contentPreview}
656 />
657 </div>
658 </div>
659 );
660 };
661
662 // Memoize the component to prevent unnecessary re-renders
663 export default memo(MetaboxApp);
664