PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.15.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.15.0
2.15.0 2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 59 releases
thinkrank / src / admin / components / seo / ImprovementSuggestions.js

ImprovementSuggestions.js in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.15.0, at src/admin/components/seo/ImprovementSuggestions.js

573 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * SEO Improvement Suggestions Component
3 *
4 * Displays actionable SEO improvement recommendations
5 *
6 * @package
7 * @since 1.0.0
8 */
9
10 import { __ } from '@wordpress/i18n';
11 import { useState } from '@wordpress/element';
12 import Button from '../ui/Button';
13
14 /**
15 * Improvement Suggestions Component
16 *
17 * @param {Object} props Component props
18 * @param {Array} props.suggestions Array of suggestion strings
19 * @param {Function} props.onApplySuggestion Callback for applying suggestions
20 * @param {Function} props.resolveSuggestionKind Returns what the action button will do for a suggestion
21 * @param {Function} props.onExplainSuggestion Async callback that returns an AI explanation for a suggestion
22 * @param {boolean} props.aiConfigured Whether an AI provider key is configured
23 * @param {Function} props.onDismissSuggestion Callback for dismissing suggestions
24 * @return {JSX.Element} Improvement suggestions component
25 */
26 const ImprovementSuggestions = ({
27 suggestions = [],
28 onApplySuggestion,
29 resolveSuggestionKind = null,
30 onExplainSuggestion,
31 aiConfigured = true,
32 onDismissSuggestion,
33 }) => {
34 const [dismissedSuggestions, setDismissedSuggestions] = useState(new Set());
35 const [applyingIndex, setApplyingIndex] = useState(null);
36 // Per-suggestion AI explanation state, keyed by normalized suggestion text
37 // (stable across list re-filtering): { [text]: { loading, text, error } }.
38 const [explanations, setExplanations] = useState({});
39
40 // Normalize a suggestion (string or { text }) to display text.
41 const suggestionToText = (suggestion) => {
42 if (typeof suggestion === 'string') return suggestion;
43 if (suggestion && suggestion.text) return suggestion.text;
44 return String(suggestion);
45 };
46
47 /**
48 * Handle applying a suggestion
49 * @param suggestion
50 * @param index
51 */
52 const handleApplySuggestion = async (suggestion, index) => {
53 if (!onApplySuggestion) return;
54
55 setApplyingIndex(index);
56 try {
57 await onApplySuggestion(suggestion, index);
58 } finally {
59 setApplyingIndex(null);
60 }
61 };
62
63 /**
64 * Fetch (or toggle) an AI explanation for a suggestion. Re-clicking while an
65 * explanation is shown collapses it; re-clicking after an error retries.
66 *
67 * Keyed by the normalized suggestion text (a stable identity) rather than the
68 * list index, so an explanation stays attached to its suggestion even after
69 * the list is re-filtered by an apply/dismiss elsewhere.
70 *
71 * @param {string|Object} suggestion The suggestion being explained.
72 */
73 const handleExplainSuggestion = async (suggestion) => {
74 if (!onExplainSuggestion) return;
75
76 const key = suggestionToText(suggestion);
77 const current = explanations[key];
78 // Collapse if we already have a successful explanation shown.
79 if (current && current.text && !current.loading) {
80 setExplanations((prev) => {
81 const next = { ...prev };
82 delete next[key];
83 return next;
84 });
85 return;
86 }
87 if (current && current.loading) return;
88
89 setExplanations((prev) => ({
90 ...prev,
91 [key]: { loading: true, text: '', error: '' },
92 }));
93
94 const result = await onExplainSuggestion(suggestion);
95 setExplanations((prev) => ({
96 ...prev,
97 [key]: {
98 loading: false,
99 text: result && result.ok ? result.explanation : '',
100 error: result && result.ok ? '' : result?.message || '',
101 },
102 }));
103 };
104
105 /**
106 * Handle dismissing a suggestion
107 * @param suggestion
108 * @param index
109 */
110 const handleDismissSuggestion = (suggestion, index) => {
111 // Call parent handler first (it may show confirmation)
112 let shouldDismiss = true;
113 if (onDismissSuggestion) {
114 shouldDismiss = onDismissSuggestion(suggestion, index);
115 }
116
117 // Only dismiss if parent handler allows it
118 if (shouldDismiss !== false) {
119 setDismissedSuggestions((prev) => new Set([...prev, index]));
120 }
121 };
122
123 /**
124 * Get suggestion priority level - Enhanced to match jQuery implementation
125 * @param suggestion
126 */
127 const getSuggestionPriority = (suggestion) => {
128 // Handle both string and object formats (matching jQuery implementation)
129 let suggestionText = '';
130 let explicitPriority = null;
131
132 if (typeof suggestion === 'string') {
133 suggestionText = suggestion;
134 } else if (suggestion && suggestion.text) {
135 suggestionText = suggestion.text;
136 explicitPriority = suggestion.priority;
137 } else {
138 suggestionText = String(suggestion);
139 }
140
141 // If explicit priority is set, use it
142 if (explicitPriority) {
143 return explicitPriority.toLowerCase();
144 }
145
146 // Enhanced keyword detection for better priority classification
147 const suggestionLower = suggestionText.toLowerCase();
148
149 // High priority indicators
150 const highPriorityKeywords = [
151 'missing',
152 'required',
153 'must',
154 'critical',
155 'important',
156 'essential',
157 'no h1',
158 'no title',
159 'no meta description',
160 'too short',
161 'too long',
162 'duplicate',
163 'broken',
164 'error',
165 'invalid',
166 'empty',
167 ];
168
169 // Low priority indicators
170 const lowPriorityKeywords = [
171 'consider',
172 'might',
173 'could',
174 'optional',
175 'suggestion',
176 'recommend',
177 'try',
178 'perhaps',
179 'maybe',
180 'potentially',
181 'enhance',
182 'improve slightly',
183 ];
184
185 // Medium priority indicators
186 const mediumPriorityKeywords = [
187 'should',
188 'add',
189 'include',
190 'optimize',
191 'update',
192 'modify',
193 'adjust',
194 'review',
195 'check',
196 'ensure',
197 'verify',
198 ];
199
200 if (
201 highPriorityKeywords.some((keyword) =>
202 suggestionLower.includes(keyword)
203 )
204 ) {
205 return 'high';
206 }
207
208 if (
209 lowPriorityKeywords.some((keyword) =>
210 suggestionLower.includes(keyword)
211 )
212 ) {
213 return 'low';
214 }
215
216 if (
217 mediumPriorityKeywords.some((keyword) =>
218 suggestionLower.includes(keyword)
219 )
220 ) {
221 return 'medium';
222 }
223
224 // Default to medium priority
225 return 'medium';
226 };
227
228 /**
229 * Get suggestion category based on content - Enhanced categorization
230 * @param suggestion
231 */
232 const getSuggestionCategory = (suggestion) => {
233 // Handle both string and object formats
234 let suggestionText = '';
235 if (typeof suggestion === 'string') {
236 suggestionText = suggestion;
237 } else if (suggestion && suggestion.text) {
238 suggestionText = suggestion.text;
239 } else {
240 suggestionText = String(suggestion);
241 }
242
243 const suggestionLower = suggestionText.toLowerCase();
244
245 // Enhanced category detection
246 if (suggestionLower.includes('title') || suggestionLower.includes('h1'))
247 return 'title';
248 if (
249 suggestionLower.includes('meta description') ||
250 suggestionLower.includes('description')
251 )
252 return 'meta';
253 if (
254 suggestionLower.includes('heading') ||
255 suggestionLower.includes('h2') ||
256 suggestionLower.includes('h3') ||
257 suggestionLower.includes('subheading')
258 )
259 return 'headings';
260 if (
261 suggestionLower.includes('keyword') ||
262 suggestionLower.includes('focus keyword') ||
263 suggestionLower.includes('density')
264 )
265 return 'keywords';
266 if (
267 suggestionLower.includes('content') ||
268 suggestionLower.includes('word count') ||
269 suggestionLower.includes('paragraph')
270 )
271 return 'content';
272 if (
273 suggestionLower.includes('readability') ||
274 suggestionLower.includes('flesch') ||
275 suggestionLower.includes('reading')
276 )
277 return 'readability';
278 if (
279 suggestionLower.includes('link') ||
280 suggestionLower.includes('internal') ||
281 suggestionLower.includes('external')
282 )
283 return 'links';
284 if (
285 suggestionLower.includes('image') ||
286 suggestionLower.includes('alt') ||
287 suggestionLower.includes('media')
288 )
289 return 'images';
290 if (
291 suggestionLower.includes('url') ||
292 suggestionLower.includes('permalink') ||
293 suggestionLower.includes('slug')
294 )
295 return 'url';
296 if (
297 suggestionLower.includes('mobile') ||
298 suggestionLower.includes('responsive')
299 )
300 return 'mobile';
301 if (
302 suggestionLower.includes('speed') ||
303 suggestionLower.includes('performance') ||
304 suggestionLower.includes('loading')
305 )
306 return 'performance';
307
308 return 'general';
309 };
310
311 // Filter out dismissed suggestions
312 const activeSuggestions = suggestions.filter(
313 (_, index) => !dismissedSuggestions.has(index)
314 );
315
316 if (activeSuggestions.length === 0) {
317 return (
318 <div className="tr-py-8 tr-text-center">
319 <div className="tr-text-3xl tr-mb-2">�
320 </div>
321 <h4 className="tr-text-sm tr-font-semibold tr-text-primary tr-m-0 tr-mb-1">
322 {__('Excellent Work!', 'thinkrank')}
323 </h4>
324 <p className="tr-text-xs tr-text-tertiary tr-m-0">
325 {__(
326 'No immediate SEO improvements needed. Your content is well optimized!',
327 'thinkrank'
328 )}
329 </p>
330 </div>
331 );
332 }
333
334 // Group suggestions by priority
335 const groupedSuggestions = activeSuggestions.reduce(
336 (groups, suggestion, originalIndex) => {
337 const priority = getSuggestionPriority(suggestion);
338 if (!groups[priority]) groups[priority] = [];
339 groups[priority].push({ suggestion, originalIndex });
340 return groups;
341 },
342 {}
343 );
344
345 const priorityOrder = ['high', 'medium', 'low'];
346 const PRIORITY = {
347 high: {
348 label: __('High priority', 'thinkrank'),
349 badge: 'tr-bg-danger-bg tr-text-danger',
350 dot: 'tr-bg-danger',
351 },
352 medium: {
353 label: __('Medium priority', 'thinkrank'),
354 badge: 'tr-bg-warning-bg tr-text-warning',
355 dot: 'tr-bg-warning',
356 },
357 low: {
358 label: __('Low priority', 'thinkrank'),
359 badge: 'tr-bg-success-bg tr-text-success',
360 dot: 'tr-bg-success',
361 },
362 };
363
364 return (
365 <div className="tr-flex tr-flex-col tr-gap-4">
366 {/* Header */}
367 <div className="tr-flex tr-items-center tr-justify-between tr-gap-2">
368 <div className="tr-flex tr-items-center tr-gap-2">
369 <span className="tr-text-base">💡</span>
370 <h4 className="tr-text-sm tr-font-semibold tr-text-primary tr-m-0">
371 {__('Improvement Suggestions', 'thinkrank')}
372 </h4>
373 </div>
374 <span className="tr-text-xs tr-text-tertiary">
375 {activeSuggestions.length}{' '}
376 {activeSuggestions.length === 1
377 ? __('suggestion', 'thinkrank')
378 : __('suggestions', 'thinkrank')}
379 </span>
380 </div>
381
382 {/* Priority groups */}
383 {priorityOrder.map((priority) => {
384 if (!groupedSuggestions[priority]) return null;
385 const cfg = PRIORITY[priority];
386 return (
387 <div
388 key={priority}
389 className="tr-flex tr-flex-col tr-gap-2"
390 >
391 <div className="tr-flex tr-items-center tr-gap-2">
392 <span
393 className={`tr-w-2 tr-h-2 tr-rounded-full ${cfg.dot}`}
394 />
395 <span className="tr-text-[11px] tr-font-semibold tr-uppercase tr-tracking-wide tr-text-tertiary">
396 {cfg.label}
397 </span>
398 <span className="tr-text-[11px] tr-text-tertiary">
399 ({groupedSuggestions[priority].length})
400 </span>
401 </div>
402
403 {groupedSuggestions[priority].map(
404 ({ suggestion, originalIndex }) => {
405 const category =
406 getSuggestionCategory(suggestion);
407 const isApplying =
408 applyingIndex === originalIndex;
409 const suggestionText =
410 suggestionToText(suggestion);
411 const explain = explanations[suggestionText];
412 const isExplaining = Boolean(explain?.loading);
413 // Most suggestions can't be auto-applied: they
414 // open step-by-step guidance instead. Labelling
415 // those "Apply" promised an edit that never
416 // arrived, which is what made the whole feature
417 // read as broken.
418 const isGuidanceOnly = resolveSuggestionKind
419 ? resolveSuggestionKind(suggestion) ===
420 'guidance'
421 : false;
422
423 return (
424 <div
425 key={suggestionText}
426 className="tr-flex tr-flex-col tr-gap-2 tr-p-3 tr-rounded-lg tr-bg-white tr-border tr-border-secondary"
427 >
428 <div className="tr-flex tr-items-center tr-gap-1.5 tr-flex-wrap">
429 <span className="tr-inline-flex tr-items-center tr-h-5 tr-px-2 tr-rounded-full tr-text-[10px] tr-font-medium tr-bg-line-2 tr-text-secondary tr-capitalize">
430 {category}
431 </span>
432 <span
433 className={`tr-inline-flex tr-items-center tr-h-5 tr-px-2 tr-rounded-full tr-text-[10px] tr-font-semibold ${cfg.badge}`}
434 >
435 {cfg.label}
436 </span>
437 </div>
438 <p className="tr-text-sm tr-text-secondary tr-m-0 tr-leading-relaxed">
439 {suggestionText}
440 </p>
441 {(isExplaining ||
442 explain?.text ||
443 explain?.error) && (
444 <div
445 className={`tr-p-3 tr-rounded-lg tr-text-sm tr-leading-7 ${
446 explain?.error
447 ? 'tr-bg-danger-bg tr-text-danger'
448 : 'tr-bg-brand-bg tr-text-primary'
449 }`}
450 >
451 {isExplaining
452 ? __(
453 'Asking AI why this matters…',
454 'thinkrank'
455 )
456 : explain?.error ||
457 explain?.text}
458 </div>
459 )}
460 <div className="tr-flex tr-gap-2">
461 {onApplySuggestion && (
462 <Button
463 variant={
464 isGuidanceOnly
465 ? 'secondary'
466 : 'primary'
467 }
468 size="sm"
469 loading={isApplying}
470 title={
471 isGuidanceOnly
472 ? __(
473 'Shows step-by-step guidance, so you make this change yourself',
474 'thinkrank'
475 )
476 : __(
477 'ThinkRank makes this change for you',
478 'thinkrank'
479 )
480 }
481 onClick={() =>
482 handleApplySuggestion(
483 suggestion,
484 originalIndex
485 )
486 }
487 >
488 {isApplying
489 ? __(
490 'Applying…',
491 'thinkrank'
492 )
493 : isGuidanceOnly
494 ? __(
495 'How to fix',
496 'thinkrank'
497 )
498 : __(
499 'Apply with AI',
500 'thinkrank'
501 )}
502 </Button>
503 )}
504 {onExplainSuggestion && (
505 <Button
506 variant="secondary"
507 size="sm"
508 disabled={
509 isExplaining ||
510 !aiConfigured
511 }
512 title={
513 !aiConfigured
514 ? __(
515 'Connect an AI provider in ThinkRank → Settings to enable AI explanations',
516 'thinkrank'
517 )
518 : undefined
519 }
520 onClick={() =>
521 handleExplainSuggestion(
522 suggestion
523 )
524 }
525 >
526 {explain?.text &&
527 !isExplaining
528 ? __(
529 'Hide',
530 'thinkrank'
531 )
532 : __(
533 'Explain with AI',
534 'thinkrank'
535 )}
536 </Button>
537 )}
538 <Button
539 variant="secondary"
540 size="sm"
541 onClick={() =>
542 handleDismissSuggestion(
543 suggestion,
544 originalIndex
545 )
546 }
547 >
548 {__('Dismiss', 'thinkrank')}
549 </Button>
550 </div>
551 </div>
552 );
553 }
554 )}
555 </div>
556 );
557 })}
558
559 {dismissedSuggestions.size > 0 && (
560 <div className="tr-text-xs tr-text-tertiary tr-text-center">
561 {dismissedSuggestions.size}{' '}
562 {dismissedSuggestions.size === 1
563 ? __('suggestion', 'thinkrank')
564 : __('suggestions', 'thinkrank')}{' '}
565 {__('dismissed', 'thinkrank')}
566 </div>
567 )}
568 </div>
569 );
570 };
571
572 export default ImprovementSuggestions;
573