| 1 |
/** |
| 2 |
* SEO Score Breakdown Component |
| 3 |
* |
| 4 |
* Displays detailed breakdown of the 10-factor SEO scoring system |
| 5 |
* |
| 6 |
* @package ThinkRank |
| 7 |
* @since 1.0.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
import { __ } from '@wordpress/i18n'; |
| 11 |
import { useState, useEffect } from '@wordpress/element'; |
| 12 |
import { Card, CardBody, CardHeader, ProgressBar, Button, Flex, FlexItem } from '@wordpress/components'; |
| 13 |
import { chevronDown, chevronUp } from '@wordpress/icons'; |
| 14 |
|
| 15 |
/** |
| 16 |
* Score Breakdown Component |
| 17 |
* |
| 18 |
* @param {Object} props Component props |
| 19 |
* @param {Object} props.scoreData Complete scoring data from API |
| 20 |
* @param {Function} props.onRefresh Callback for refreshing scores (optional) |
| 21 |
* @param {boolean} props.showOverview Whether to show the overview card (default: true) |
| 22 |
* @returns {JSX.Element} Score breakdown component |
| 23 |
*/ |
| 24 |
const ScoreBreakdown = ({ scoreData, onRefresh = null, showOverview = true }) => { |
| 25 |
const [expandedSections, setExpandedSections] = useState({}); |
| 26 |
const [isRefreshing, setIsRefreshing] = useState(false); |
| 27 |
|
| 28 |
// 2025 SEO Algorithm Factors (Google Q1 2025 Update) |
| 29 |
// Based on First Page Sage research and latest Google algorithm changes |
| 30 |
const scoreFactors = { |
| 31 |
satisfying_content: { |
| 32 |
label: __('Satisfying Content', 'thinkrank'), |
| 33 |
description: __('#1 ranking factor - Consistent publication of satisfying content (23%)', 'thinkrank'), |
| 34 |
maxScore: 23, |
| 35 |
icon: '🎯' |
| 36 |
}, |
| 37 |
title_optimization: { |
| 38 |
label: __('Title Optimization', 'thinkrank'), |
| 39 |
description: __('Keyword in meta title with looser matching requirements (14%)', 'thinkrank'), |
| 40 |
maxScore: 14, |
| 41 |
icon: '📝' |
| 42 |
}, |
| 43 |
niche_expertise: { |
| 44 |
label: __('Niche Expertise', 'thinkrank'), |
| 45 |
description: __('Hub & spoke content clusters demonstrating expertise (13%)', 'thinkrank'), |
| 46 |
maxScore: 13, |
| 47 |
icon: '🧠' |
| 48 |
}, |
| 49 |
searcher_engagement: { |
| 50 |
label: __('Searcher Engagement', 'thinkrank'), |
| 51 |
description: __('Dwell time, bounce rate, pages per session metrics (12%)', 'thinkrank'), |
| 52 |
maxScore: 12, |
| 53 |
icon: '👥' |
| 54 |
}, |
| 55 |
backlink_authority: { |
| 56 |
label: __('Backlink Authority', 'thinkrank'), |
| 57 |
description: __('Quality backlinks - declining but still important (13%)', 'thinkrank'), |
| 58 |
maxScore: 13, |
| 59 |
icon: '🔗' |
| 60 |
}, |
| 61 |
content_freshness: { |
| 62 |
label: __('Content Freshness', 'thinkrank'), |
| 63 |
description: __('Quarterly content updates and maintenance (6%)', 'thinkrank'), |
| 64 |
maxScore: 6, |
| 65 |
icon: '🔄' |
| 66 |
}, |
| 67 |
mobile_experience: { |
| 68 |
label: __('Mobile Experience Score', 'thinkrank'), |
| 69 |
description: __('NEW 2025: Mobile Experience Score (MES) replacing mobile-first (5%)', 'thinkrank'), |
| 70 |
maxScore: 5, |
| 71 |
icon: '📱' |
| 72 |
}, |
| 73 |
trustworthiness: { |
| 74 |
label: __('Trustworthiness', 'thinkrank'), |
| 75 |
description: __('E-A-T signals and brand authority indicators (5%)', 'thinkrank'), |
| 76 |
maxScore: 5, |
| 77 |
icon: '🛡️' |
| 78 |
}, |
| 79 |
link_diversity: { |
| 80 |
label: __('Link Distribution Diversity', 'thinkrank'), |
| 81 |
description: __('Diverse link profile and natural link patterns (4%)', 'thinkrank'), |
| 82 |
maxScore: 4, |
| 83 |
icon: '🌐' |
| 84 |
}, |
| 85 |
core_web_vitals: { |
| 86 |
label: __('Core Web Vitals 2.0', 'thinkrank'), |
| 87 |
description: __('Updated 2025 performance metrics and user experience (3%)', 'thinkrank'), |
| 88 |
maxScore: 3, |
| 89 |
icon: '⚡' |
| 90 |
}, |
| 91 |
site_security: { |
| 92 |
label: __('Site Security', 'thinkrank'), |
| 93 |
description: __('HTTPS, security headers, and trust signals (1%)', 'thinkrank'), |
| 94 |
maxScore: 1, |
| 95 |
icon: '🔒' |
| 96 |
}, |
| 97 |
internal_linking: { |
| 98 |
label: __('Internal Linking', 'thinkrank'), |
| 99 |
description: __('Strategic internal link structure and content clusters (1%)', 'thinkrank'), |
| 100 |
maxScore: 1, |
| 101 |
icon: '🔗' |
| 102 |
} |
| 103 |
}; |
| 104 |
|
| 105 |
/** |
| 106 |
* Toggle expanded state for a section |
| 107 |
*/ |
| 108 |
const toggleSection = (factorKey) => { |
| 109 |
setExpandedSections(prev => ({ |
| 110 |
...prev, |
| 111 |
[factorKey]: !prev[factorKey] |
| 112 |
})); |
| 113 |
}; |
| 114 |
|
| 115 |
/** |
| 116 |
* Get score color class based on percentage |
| 117 |
*/ |
| 118 |
const getScoreColor = (score, maxScore) => { |
| 119 |
const percentage = (score / maxScore) * 100; |
| 120 |
if (percentage >= 90) return 'score-excellent'; |
| 121 |
if (percentage >= 70) return 'score-good'; |
| 122 |
if (percentage >= 50) return 'score-fair'; |
| 123 |
return 'score-poor'; |
| 124 |
}; |
| 125 |
|
| 126 |
/** |
| 127 |
* Handle refresh scores |
| 128 |
*/ |
| 129 |
const handleRefresh = async () => { |
| 130 |
if (!onRefresh) return; |
| 131 |
|
| 132 |
setIsRefreshing(true); |
| 133 |
try { |
| 134 |
await onRefresh(); |
| 135 |
} finally { |
| 136 |
setIsRefreshing(false); |
| 137 |
} |
| 138 |
}; |
| 139 |
|
| 140 |
/** |
| 141 |
* Convert technical details to human-readable format |
| 142 |
*/ |
| 143 |
const formatFactorDetails = (factorKey, details) => { |
| 144 |
if (!details || typeof details !== 'object') return null; |
| 145 |
|
| 146 |
const formatters = { |
| 147 |
satisfying_content: (d) => [ |
| 148 |
`Word count: ${d.word_count || 0} words (saved content)`, |
| 149 |
`Content satisfaction: ${d.intent_satisfaction || 0}%`, |
| 150 |
`Content uniqueness: ${d.uniqueness_score || 0}%`, |
| 151 |
`Content depth: ${d.content_depth || 'Basic'}` |
| 152 |
], |
| 153 |
title_optimization: (d) => [ |
| 154 |
`Title length: ${d.title_length || 0} characters`, |
| 155 |
`Keyword in title: ${d.keyword_in_title ? 'Yes' : 'No'}`, |
| 156 |
`Title optimization: ${d.title_score || 0}%` |
| 157 |
], |
| 158 |
niche_expertise: (d) => [ |
| 159 |
`Topic coverage: ${d.topic_coverage || 'Basic'}`, |
| 160 |
`Content depth: ${d.content_depth || 'Standard'}`, |
| 161 |
`Expertise signals: ${d.expertise_score || 0}%` |
| 162 |
], |
| 163 |
searcher_engagement: (d) => [ |
| 164 |
`Content engagement: ${d.engagement_score || 0}%`, |
| 165 |
`Reading time: ${d.estimated_reading_time || 'Unknown'}`, |
| 166 |
`User experience: ${d.ux_score || 'Good'}` |
| 167 |
], |
| 168 |
backlink_authority: (d) => [ |
| 169 |
`Authority score: ${d.authority_score || 0}%`, |
| 170 |
`Link quality: ${d.link_quality || 'Unknown'}`, |
| 171 |
`Domain authority: ${d.domain_authority || 'Not assessed'}` |
| 172 |
], |
| 173 |
content_freshness: (d) => [ |
| 174 |
`Freshness status: ${d.freshness_status || 'Current'}`, |
| 175 |
`Last updated: ${d.last_updated ? new Date(d.last_updated).toLocaleDateString() : 'Unknown'}` |
| 176 |
], |
| 177 |
mobile_experience: (d) => [ |
| 178 |
`Mobile score: ${d.mes_score || 'Good'}`, |
| 179 |
`Mobile friendly: ${d.mobile_friendly ? 'Yes' : 'No'}`, |
| 180 |
`Page speed: ${d.page_speed || 'Not tested'}` |
| 181 |
], |
| 182 |
technical_factors: (d) => [ |
| 183 |
`Meta description: ${d.meta_description_length || 0} characters`, |
| 184 |
`Schema markup: ${d.schema_present ? 'Present' : 'Missing'}`, |
| 185 |
`Technical score: ${d.technical_score || 0}%` |
| 186 |
] |
| 187 |
}; |
| 188 |
|
| 189 |
const formatter = formatters[factorKey]; |
| 190 |
return formatter ? formatter(details) : null; |
| 191 |
}; |
| 192 |
|
| 193 |
if (!scoreData || !scoreData.score_breakdown) { |
| 194 |
return ( |
| 195 |
<Card> |
| 196 |
<CardBody> |
| 197 |
<p>{__('No scoring data available. Run an SEO analysis to see detailed breakdown.', 'thinkrank')}</p> |
| 198 |
</CardBody> |
| 199 |
</Card> |
| 200 |
); |
| 201 |
} |
| 202 |
|
| 203 |
const { overall_score, score_breakdown, grade, calculated_at } = scoreData; |
| 204 |
|
| 205 |
return ( |
| 206 |
<div className="thinkrank-score-breakdown"> |
| 207 |
{/* Overall Score Header - Only show if showOverview is true */} |
| 208 |
{showOverview && ( |
| 209 |
<Card className="score-overview-card"> |
| 210 |
<CardHeader> |
| 211 |
<Flex justify="space-between" align="center"> |
| 212 |
<FlexItem> |
| 213 |
<h3>{__('SEO Score Breakdown', 'thinkrank')}</h3> |
| 214 |
</FlexItem> |
| 215 |
{onRefresh && ( |
| 216 |
<FlexItem> |
| 217 |
<Button |
| 218 |
variant="secondary" |
| 219 |
onClick={handleRefresh} |
| 220 |
isBusy={isRefreshing} |
| 221 |
disabled={isRefreshing} |
| 222 |
> |
| 223 |
{__('Refresh', 'thinkrank')} |
| 224 |
</Button> |
| 225 |
</FlexItem> |
| 226 |
)} |
| 227 |
</Flex> |
| 228 |
</CardHeader> |
| 229 |
<CardBody> |
| 230 |
<div className="overall-score-display"> |
| 231 |
<div className={`score-circle ${getScoreColor(overall_score, 100)}`}> |
| 232 |
<span className="score-number">{overall_score}</span> |
| 233 |
<span className="score-grade">{grade}</span> |
| 234 |
</div> |
| 235 |
<div className="score-info"> |
| 236 |
<p className="score-description"> |
| 237 |
{overall_score >= 80 && __('Excellent SEO optimization!', 'thinkrank')} |
| 238 |
{overall_score >= 60 && overall_score < 80 && __('Good SEO with room for improvement', 'thinkrank')} |
| 239 |
{overall_score >= 40 && overall_score < 60 && __('Fair SEO - needs attention', 'thinkrank')} |
| 240 |
{overall_score < 40 && __('Poor SEO - requires significant work', 'thinkrank')} |
| 241 |
</p> |
| 242 |
{calculated_at && ( |
| 243 |
<p className="score-timestamp"> |
| 244 |
{__('Last updated:', 'thinkrank')} {new Date(calculated_at).toLocaleString()} |
| 245 |
</p> |
| 246 |
)} |
| 247 |
</div> |
| 248 |
</div> |
| 249 |
</CardBody> |
| 250 |
</Card> |
| 251 |
)} |
| 252 |
|
| 253 |
{/* Detailed Factor Breakdown */} |
| 254 |
<Card className="score-factors-card"> |
| 255 |
<CardHeader> |
| 256 |
<h4>{__('Scoring Factors', 'thinkrank')}</h4> |
| 257 |
</CardHeader> |
| 258 |
<CardBody> |
| 259 |
<div className="score-factors-list"> |
| 260 |
{Object.entries(scoreFactors).map(([factorKey, factorConfig]) => { |
| 261 |
const factorData = score_breakdown[factorKey]; |
| 262 |
if (!factorData) return null; |
| 263 |
|
| 264 |
const isExpanded = expandedSections[factorKey]; |
| 265 |
const percentage = (factorData.score / factorData.max_score) * 100; |
| 266 |
|
| 267 |
return ( |
| 268 |
<div key={factorKey} className="score-factor-item"> |
| 269 |
<div |
| 270 |
className="factor-header" |
| 271 |
onClick={() => toggleSection(factorKey)} |
| 272 |
role="button" |
| 273 |
tabIndex={0} |
| 274 |
> |
| 275 |
<Flex justify="space-between" align="center"> |
| 276 |
<FlexItem> |
| 277 |
<div className="factor-info"> |
| 278 |
<span className="factor-icon">{factorConfig.icon}</span> |
| 279 |
<div className="factor-text"> |
| 280 |
<span className="factor-label">{factorConfig.label}</span> |
| 281 |
<span className="factor-description">{factorConfig.description}</span> |
| 282 |
</div> |
| 283 |
</div> |
| 284 |
</FlexItem> |
| 285 |
<FlexItem className="factor-score-container"> |
| 286 |
<div className="factor-score"> |
| 287 |
<span className="score-value"> |
| 288 |
{factorData.score}/{factorData.max_score} |
| 289 |
</span> |
| 290 |
<div className="score-bar"> |
| 291 |
<div |
| 292 |
className={`score-bar-fill ${getScoreColor(factorData.score, factorData.max_score)}`} |
| 293 |
style={{ width: `${percentage}%` }} |
| 294 |
/> |
| 295 |
</div> |
| 296 |
</div> |
| 297 |
</FlexItem> |
| 298 |
<FlexItem> |
| 299 |
<Button |
| 300 |
variant="tertiary" |
| 301 |
icon={isExpanded ? chevronUp : chevronDown} |
| 302 |
size="small" |
| 303 |
/> |
| 304 |
</FlexItem> |
| 305 |
</Flex> |
| 306 |
</div> |
| 307 |
|
| 308 |
{isExpanded && ( |
| 309 |
<div className="factor-details"> |
| 310 |
{factorData.suggestions && factorData.suggestions.length > 0 && ( |
| 311 |
<div className="factor-suggestions"> |
| 312 |
<h5>{__('Suggestions:', 'thinkrank')}</h5> |
| 313 |
<ul> |
| 314 |
{factorData.suggestions.map((suggestion, index) => ( |
| 315 |
<li key={index}>{suggestion}</li> |
| 316 |
))} |
| 317 |
</ul> |
| 318 |
</div> |
| 319 |
)} |
| 320 |
|
| 321 |
{factorData.details && ( |
| 322 |
<div className="factor-technical-details"> |
| 323 |
<h5>{__('Details:', 'thinkrank')}</h5> |
| 324 |
<div className="details-list"> |
| 325 |
{formatFactorDetails(factorKey, factorData.details)?.map((detail, index) => ( |
| 326 |
<div key={index} className="detail-item"> |
| 327 |
{detail} |
| 328 |
</div> |
| 329 |
)) || ( |
| 330 |
<div className="detail-item"> |
| 331 |
{__('Technical details available', 'thinkrank')} |
| 332 |
</div> |
| 333 |
)} |
| 334 |
</div> |
| 335 |
</div> |
| 336 |
)} |
| 337 |
</div> |
| 338 |
)} |
| 339 |
</div> |
| 340 |
); |
| 341 |
})} |
| 342 |
</div> |
| 343 |
</CardBody> |
| 344 |
</Card> |
| 345 |
</div> |
| 346 |
); |
| 347 |
}; |
| 348 |
|
| 349 |
export default ScoreBreakdown; |
| 350 |
|