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 / essential-seo / performance / CWVSection.js

CWVSection.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/essential-seo/performance/CWVSection.js

455 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Core Web Vitals Section
3 *
4 * Performance score donut, CWV metric cards, status icons.
5 *
6 * @package
7 * @since 1.0.0 (refactored 1.11.0)
8 */
9
10 import { __ } from '@wordpress/i18n';
11 import {
12 Button,
13 __experimentalText as Text,
14 __experimentalHeading as Heading,
15 } from '@wordpress/components';
16 import { SkeletonCard, EmptyState } from '../../ui';
17 import emptyStateIcon from '../../../../shared/assets/empty-state.svg';
18
19 /**
20 * Get vital status category
21 * @param value
22 * @param thresholds
23 */
24 export const getVitalStatus = (value, thresholds) => {
25 if (value <= thresholds.good) return 'good';
26 if (value <= thresholds.needs_improvement) return 'needs-improvement';
27 return 'poor';
28 };
29
30 /**
31 * Get status color hex
32 * @param status
33 */
34 export const getStatusColor = (status) => {
35 switch (status) {
36 case 'good':
37 return '#00a32a';
38 case 'needs-improvement':
39 return '#dba617';
40 case 'poor':
41 return '#d63638';
42 default:
43 return '#50575e';
44 }
45 };
46
47 /**
48 * Get performance score color
49 * @param score
50 */
51 export const getScoreColor = (score) => {
52 if (score >= 90) return '#00a32a';
53 if (score >= 50) return '#dba617';
54 return '#d63638';
55 };
56
57 /**
58 * Render status icon SVG
59 * @param status
60 */
61 const renderStatusIcon = (status) => {
62 if (status === 'good') {
63 return (
64 <span className="thinkrank-cwv-status-icon">
65 <svg width="22" height="22" viewBox="0 0 22 22" fill="none">
66 <circle cx="11" cy="11" r="11" fill="#34a853" />
67 <path
68 d="M6.5 11.5l3 3 6-7"
69 stroke="#fff"
70 strokeWidth="2"
71 strokeLinecap="round"
72 strokeLinejoin="round"
73 />
74 </svg>
75 </span>
76 );
77 }
78 if (status === 'needs-improvement') {
79 return (
80 <span className="thinkrank-cwv-status-icon">
81 <svg width="22" height="22" viewBox="0 0 22 22" fill="none">
82 <path d="M11 2L1 20h20L11 2z" fill="#B54708" />
83 <path
84 d="M11 9v5"
85 stroke="#fff"
86 strokeWidth="2"
87 strokeLinecap="round"
88 />
89 <circle cx="11" cy="16.5" r="1" fill="#fff" />
90 </svg>
91 </span>
92 );
93 }
94 return (
95 <span className="thinkrank-cwv-status-icon">
96 <svg width="22" height="22" viewBox="0 0 22 22" fill="none">
97 <rect width="22" height="22" rx="5" fill="#921C13" />
98 <path
99 d="M7 7.5c0-2.2 1.8-4 4-4s4 1.8 4 4"
100 stroke="#fff"
101 strokeWidth="1.8"
102 strokeLinecap="round"
103 fill="none"
104 />
105 <circle cx="9" cy="10" r="1.2" fill="#fff" />
106 <circle cx="13" cy="10" r="1.2" fill="#fff" />
107 <path
108 d="M8 14.5s1.2-2 3-2 3 2 3 2"
109 stroke="#fff"
110 strokeWidth="1.8"
111 strokeLinecap="round"
112 fill="none"
113 />
114 </svg>
115 </span>
116 );
117 };
118
119 /**
120 * Render a single vital card
121 * @param vital
122 * @param data
123 */
124 const renderVitalCard = (vital, data) => {
125 if (!data) return null;
126
127 if (data.status === 'unknown' || data.value === 0) {
128 return (
129 <div key={vital} className="thinkrank-cwv-metric-card">
130 <div className="thinkrank-cwv-metric-header">
131 <span className="thinkrank-cwv-metric-name">
132 {data.name}
133 </span>
134 </div>
135 <div className="thinkrank-cwv-metric-value">—</div>
136 <div className="thinkrank-cwv-metric-desc">
137 {__('Data not available', 'thinkrank')}
138 </div>
139 </div>
140 );
141 }
142
143 const status = getVitalStatus(data.value, {
144 good: data.good_threshold,
145 needs_improvement: data.needs_improvement_threshold,
146 });
147
148 return (
149 <div key={vital} className="thinkrank-cwv-metric-card">
150 <div className="thinkrank-cwv-metric-header">
151 <span className="thinkrank-cwv-metric-name">{data.name}</span>
152 {renderStatusIcon(status)}
153 </div>
154 <div className="thinkrank-cwv-metric-value">
155 {data.value}
156 {data.unit}
157 </div>
158 <div className="thinkrank-cwv-metric-desc">
159 {data.description ||
160 __(
161 'Time until the largest content element is rendered',
162 'thinkrank'
163 )}
164 </div>
165 <div className="thinkrank-cwv-metric-thresholds">
166 <div className="thinkrank-cwv-threshold-row">
167 <span>{__('Good', 'thinkrank')}</span>
168 <span className="thinkrank-cwv-threshold-good">
169 ↑{data.good_threshold}
170 {data.unit}
171 </span>
172 </div>
173 <div className="thinkrank-cwv-threshold-row">
174 <span>{__('Poor', 'thinkrank')}</span>
175 <span className="thinkrank-cwv-threshold-poor">
176 ↓{data.needs_improvement_threshold}
177 {data.unit}
178 </span>
179 </div>
180 </div>
181 </div>
182 );
183 };
184
185 /**
186 * Render segmented donut chart
187 * @param performanceData
188 * @param deviceType
189 * @param setDeviceType
190 */
191 const renderSegmentedDonut = (performanceData, deviceType, setDeviceType) => {
192 const score = performanceData?.performance_score || 0;
193 const cx = 110,
194 cy = 110,
195 r = 82,
196 sw = 22;
197 const circ = 2 * Math.PI * r;
198 const fillDash = (score / 100) * circ;
199 const arcColor =
200 score >= 90 ? '#027A48' : score >= 50 ? '#4451FF' : '#921C13';
201 const isMobile = deviceType === 'mobile';
202
203 return (
204 <div className="thinkrank-cwv-donut-wrap">
205 <svg width="220" height="220" viewBox="0 0 220 220">
206 <circle
207 cx={cx}
208 cy={cy}
209 r={r}
210 fill="none"
211 stroke="#eef0f7"
212 strokeWidth={sw}
213 />
214 <circle
215 cx={cx}
216 cy={cy}
217 r={r}
218 fill="none"
219 stroke={arcColor}
220 strokeWidth={sw}
221 strokeDasharray={`${fillDash} ${circ}`}
222 strokeLinecap="round"
223 transform={`rotate(-90, ${cx}, ${cy})`}
224 />
225 <text
226 x={cx}
227 y={cy - 6}
228 textAnchor="middle"
229 fontSize="38"
230 fontWeight="700"
231 fill="#1e1e1e"
232 fontFamily="Inter, sans-serif"
233 >
234 {score}
235 </text>
236 <text
237 x={cx}
238 y={cy + 20}
239 textAnchor="middle"
240 fontSize="14"
241 fill={arcColor}
242 fontFamily="Inter, sans-serif"
243 fontWeight="600"
244 >
245 {isMobile
246 ? __('Mobile', 'thinkrank')
247 : __('Desktop', 'thinkrank')}
248 </text>
249 </svg>
250
251 <div className="thinkrank-cwv-device-breakdown">
252 <button
253 type="button"
254 aria-pressed={isMobile}
255 className={`thinkrank-cwv-device-item${isMobile ? ' is-active' : ''}`}
256 onClick={() => setDeviceType('mobile')}
257 >
258 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
259 <rect
260 x="2"
261 y="3"
262 width="12"
263 height="9"
264 rx="1.5"
265 stroke="currentColor"
266 strokeWidth="1.4"
267 />
268 <path
269 d="M5.5 14h5"
270 stroke="currentColor"
271 strokeWidth="1.4"
272 strokeLinecap="round"
273 />
274 <path
275 d="M8 12v2"
276 stroke="currentColor"
277 strokeWidth="1.4"
278 strokeLinecap="round"
279 />
280 </svg>
281 <span>{__('Mobile', 'thinkrank')}</span>
282 </button>
283 <button
284 type="button"
285 aria-pressed={!isMobile}
286 className={`thinkrank-cwv-device-item${!isMobile ? ' is-active' : ''}`}
287 onClick={() => setDeviceType('desktop')}
288 >
289 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
290 <rect
291 x="1"
292 y="2"
293 width="14"
294 height="10"
295 rx="1.5"
296 stroke="currentColor"
297 strokeWidth="1.4"
298 />
299 <path
300 d="M4.5 14h7"
301 stroke="currentColor"
302 strokeWidth="1.4"
303 strokeLinecap="round"
304 />
305 <path
306 d="M8 12v2"
307 stroke="currentColor"
308 strokeWidth="1.4"
309 strokeLinecap="round"
310 />
311 </svg>
312 <span>{__('Desktop', 'thinkrank')}</span>
313 </button>
314 </div>
315 </div>
316 );
317 };
318
319 /**
320 * CWVSection Component
321 * @param root0
322 * @param root0.performanceData
323 * @param root0.deviceType
324 * @param root0.setDeviceType
325 * @param root0.isLoading
326 * @param root0.onNavigate
327 * @param root0.onRefresh
328 */
329 const CWVSection = ({
330 performanceData,
331 deviceType,
332 setDeviceType,
333 isLoading,
334 onNavigate,
335 onRefresh,
336 }) => {
337 if (isLoading) {
338 return (
339 <div className="thinkrank-cwv-wrapper">
340 <div className="tr-empty-state">
341 <SkeletonCard lines={3} />
342 </div>
343 </div>
344 );
345 }
346
347 if (
348 !performanceData ||
349 !performanceData.core_web_vitals ||
350 performanceData.core_web_vitals.error
351 ) {
352 return (
353 <div className="thinkrank-cwv-wrapper">
354 <EmptyState
355 size="md"
356 title={__('No Core Web Vitals Data Available', 'thinkrank')}
357 description={
358 performanceData?.core_web_vitals?.message ||
359 __(
360 'Add a PageSpeed API key or connect your Google account in Integrations > Google Services to view real performance data.',
361 'thinkrank'
362 )
363 }
364 action={
365 <div className="tr-flex tr-justify-center tr-gap-3">
366 <Button
367 variant="primary"
368 onClick={() =>
369 onNavigate &&
370 onNavigate(
371 'integrations',
372 'google-services'
373 )
374 }
375 >
376 {__('Add PageSpeed API Key', 'thinkrank')}
377 </Button>
378 <Button
379 variant="secondary"
380 onClick={onRefresh}
381 disabled={isLoading}
382 >
383 {isLoading ? (
384 <SkeletonCard lines={2} />
385 ) : (
386 __('Retry', 'thinkrank')
387 )}
388 </Button>
389 </div>
390 }
391 />
392 </div>
393 );
394 }
395
396 const vitals = performanceData.core_web_vitals
397 ? Object.entries(performanceData.core_web_vitals).filter(
398 ([k]) => k !== 'error' && k !== 'message'
399 )
400 : [];
401
402 return (
403 <div className="thinkrank-cwv-wrapper">
404 <div className="thinkrank-cwv-panel-layout">
405 <div className="thinkrank-cwv-score-panel">
406 <span className="thinkrank-cwv-score-title">
407 {__('Performance', 'thinkrank')}
408 </span>
409 {renderSegmentedDonut(
410 performanceData,
411 deviceType,
412 setDeviceType
413 )}
414 </div>
415 <div className="thinkrank-cwv-metrics-panel">
416 <div className="thinkrank-cwv-metrics-header">
417 <div>
418 <span className="thinkrank-cwv-metrics-title">
419 {__('Core Web', 'thinkrank')}
420 </span>
421 <span className="thinkrank-cwv-metrics-subtitle">
422 {__(
423 'Google ranking factors that measure user experience',
424 'thinkrank'
425 )}
426 </span>
427 </div>
428 <div className="thinkrank-cwv-device-tabs">
429 {['mobile', 'desktop'].map((device) => (
430 <button
431 key={device}
432 className={
433 deviceType === device ? 'active' : ''
434 }
435 onClick={() => setDeviceType(device)}
436 >
437 {device.charAt(0).toUpperCase() +
438 device.slice(1)}
439 </button>
440 ))}
441 </div>
442 </div>
443 <div className="thinkrank-cwv-metrics-grid">
444 {vitals.map(([vital, data]) =>
445 renderVitalCard(vital, data)
446 )}
447 </div>
448 </div>
449 </div>
450 </div>
451 );
452 };
453
454 export default CWVSection;
455