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 / admin / components / essential-seo / SchemaTab.js

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

1,397 lines 55.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Schema Tab Component - Complete Implementation
3 *
4 * Comprehensive schema markup management with full PHP feature parity including:
5 * - Schema settings management
6 * - Schema generation and validation
7 * - Rich snippets preview
8 * - Schema types management
9 * - Deployment options (JSON-LD, Microdata, RDFa)
10
11 * - Bulk operations
12 *
13 * @package ThinkRank
14 * @since 1.0.0
15 */
16
17 import { useState, useEffect, useRef } from '@wordpress/element';
18 import { __ } from '@wordpress/i18n';
19 import {
20 Card,
21 CardBody,
22 CardHeader,
23 Spinner,
24 Notice,
25 Button,
26 Flex,
27 FlexItem
28 } from '@wordpress/components';
29 import apiFetch from '@wordpress/api-fetch';
30
31 // Import shared components and hooks
32 import SettingsCard from '../common/SettingsCard';
33 import OptimizationButton from '../common/OptimizationButton';
34 import { validateSectionData } from '../../utils/schemaValidation';
35
36 // Import extracted schema components
37 import SchemaSettings from './schema/SchemaSettings';
38 import SchemaGeneration from './schema/SchemaGeneration';
39 import SchemaValidation from './schema/SchemaValidation';
40 import SchemaDeployment from './schema/SchemaDeployment';
41 import SchemaOrganizationForm from './schema/forms/SchemaOrganizationForm';
42 import SchemaWebsiteForm from './schema/forms/SchemaWebsiteForm';
43 import SchemaLocalBusinessForm from './schema/forms/SchemaLocalBusinessForm';
44 import SchemaPersonForm from './schema/forms/SchemaPersonForm';
45 import SchemaPreview from './schema/SchemaPreview';
46
47 // Import shared schema configuration
48 import {
49 getDefaultSettings as getSharedDefaultSettings,
50 getSupportedSchemaTypes,
51 getValidationLevelOptions as getSharedValidationLevelOptions,
52 getBooleanSettings,
53 getArraySettings,
54 getIntegerSettings,
55 getOrganizationTypeOptions,
56 getContactTypeOptions,
57 normalizeSettingsDataTypes as sharedNormalizeSettingsDataTypes,
58 isValidUrl as sharedIsValidUrl
59 } from '../../config/schema-settings-config';
60
61 /**
62 * Normalize settings data types (uses shared configuration)
63 */
64 const normalizeSettingsDataTypes = sharedNormalizeSettingsDataTypes;
65
66 /**
67 * Get default settings structure (uses shared configuration)
68 */
69 const getDefaultSettings = () => getSharedDefaultSettings('site');
70
71 /**
72 * Email validation helper
73 */
74 const isValidEmail = (email) => {
75 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
76 return emailRegex.test(email);
77 };
78
79 /**
80 * Schema Tab Component
81 */
82 const SchemaTab = ({ activeSubSection = 'schema-settings', onNavigate }) => {
83 // Component mount tracking for memory leak prevention
84 const isMountedRef = useRef(true);
85 const abortControllerRef = useRef(null);
86
87 // MEMORY OPTIMIZATION: Consolidated loading states to reduce useState hooks
88 const [loadingStates, setLoadingStates] = useState({
89 isLoading: true,
90 isSaving: false,
91 isOptimizing: false,
92 isValidating: false,
93 isGenerating: false,
94 isDeploying: false,
95 isSyncing: false
96 });
97
98 const [settings, setSettings] = useState({});
99 const [hasChanges, setHasChanges] = useState(false);
100 const [notice, setNotice] = useState(null);
101
102 /**
103 * Safe setState helper to prevent memory leaks from updates on unmounted components
104 */
105 const safeSetState = (setter, value) => {
106 if (isMountedRef.current) {
107 setter(value);
108 }
109 };
110
111 /**
112 * Safe loading state updater - consolidates multiple loading states
113 */
114 const updateLoadingState = (key, value) => {
115 if (isMountedRef.current) {
116 setLoadingStates(prev => ({ ...prev, [key]: value }));
117 }
118 };
119
120 // Extract individual loading states for backward compatibility
121 const { isLoading, isSaving, isOptimizing, isValidating, isGenerating, isDeploying, isSyncing } = loadingStates;
122
123 /**
124 * Handle setting changes
125 */
126 const handleSettingChange = (key, value) => {
127 safeSetState(setSettings, prev => ({ ...prev, [key]: value }));
128 safeSetState(setHasChanges, true);
129 };
130
131 /**
132 * Load settings from Settings Management API with memory leak protection
133 */
134 const loadSettings = async () => {
135 try {
136 // Create new AbortController for this request
137 abortControllerRef.current = new AbortController();
138
139 updateLoadingState('isLoading', true);
140 const response = await apiFetch({
141 path: '/thinkrank/v1/schema/settings?context_type=site',
142 method: 'GET',
143 signal: abortControllerRef.current.signal
144 });
145
146 if (response.success) {
147 const mergedSettings = { ...getDefaultSettings(), ...response.data.settings };
148 const normalizedSettings = normalizeSettingsDataTypes(mergedSettings);
149 safeSetState(setSettings, normalizedSettings);
150 } else {
151 safeSetState(setSettings, getDefaultSettings());
152 }
153 } catch (error) {
154 // Don't show error if request was aborted (component unmounted)
155 if (error.name !== 'AbortError') {
156 console.error('Schema settings load error:', error);
157 safeSetState(setSettings, getDefaultSettings());
158 safeSetState(setNotice, {
159 status: 'error',
160 message: __('Failed to load schema settings. Using defaults.', 'thinkrank')
161 });
162 }
163 } finally {
164 updateLoadingState('isLoading', false);
165 }
166 };
167
168 /**
169 * Save settings to Schema API
170 */
171 const saveSettings = async () => {
172 try {
173 // Create new AbortController for this request
174 abortControllerRef.current = new AbortController();
175
176 updateLoadingState('isSaving', true);
177 safeSetState(setNotice, null);
178
179 // Validate settings before saving
180 const validationErrors = validateSettings();
181 if (validationErrors.length > 0) {
182 safeSetState(setNotice, {
183 status: 'error',
184 message: (
185 <div>
186 <strong>{__('Please fix the following errors:', 'thinkrank')}</strong>
187 <ul className="thinkrank-mt-xs" style={{ paddingLeft: '20px' }}>
188 {validationErrors.map((error, index) => (
189 <li key={index}>{error}</li>
190 ))}
191 </ul>
192 </div>
193 )
194 });
195 return;
196 }
197
198 // Normalize settings data types before sending to API
199 const normalizedSettings = normalizeSettingsDataTypes(settings);
200
201 const response = await apiFetch({
202 path: '/thinkrank/v1/schema/settings',
203 method: 'POST',
204 data: {
205 settings: normalizedSettings,
206 context_type: 'site'
207 },
208 signal: abortControllerRef.current.signal
209 });
210
211 if (response.success) {
212 safeSetState(setHasChanges, false);
213 safeSetState(setNotice, {
214 status: 'success',
215 message: __('Schema settings saved successfully!', 'thinkrank')
216 });
217 } else {
218 throw new Error(response.error || 'Failed to save settings');
219 }
220 } catch (error) {
221 // Don't show error if request was aborted (component unmounted)
222 if (error.name !== 'AbortError') {
223 console.error('Schema settings save error:', error);
224
225 let errorMessage = __('Failed to save schema settings. Please try again.', 'thinkrank');
226
227 // Handle validation errors with detailed feedback
228 if (error.code === 'validation_failed' && error.data?.validation_errors) {
229 errorMessage = (
230 <div>
231 <strong>{__('Settings validation failed:', 'thinkrank')}</strong>
232 <ul className="thinkrank-mt-xs" style={{ paddingLeft: '20px' }}>
233 {error.data.validation_errors.map((validationError, index) => (
234 <li key={index}>{validationError}</li>
235 ))}
236 </ul>
237 {error.data.validation_warnings && error.data.validation_warnings.length > 0 && (
238 <>
239 <strong>{__('Warnings:', 'thinkrank')}</strong>
240 <ul className="thinkrank-mt-xs" style={{ paddingLeft: '20px' }}>
241 {error.data.validation_warnings.map((warning, index) => (
242 <li key={index}>{warning}</li>
243 ))}
244 </ul>
245 </>
246 )}
247 </div>
248 );
249 } else if (error.message) {
250 errorMessage = error.message;
251 }
252
253 safeSetState(setNotice, {
254 status: 'error',
255 message: errorMessage
256 });
257 }
258 } finally {
259 updateLoadingState('isSaving', false);
260 }
261 };
262
263 /**
264 * Run comprehensive validation for current section
265 */
266 const runValidation = async () => {
267 try {
268 updateLoadingState('isValidating', true);
269 safeSetState(setNotice, null);
270
271 const sectionData = getSectionValidationData(activeSubSection);
272 const validationResult = await validateSectionData(activeSubSection, sectionData);
273
274 updateSchemaData('validationResults', validationResult);
275
276 // Set detailed notice based on validation result
277 if (validationResult.isValid) {
278 setNotice({
279 status: 'success',
280 message: (
281 <div>
282 <strong>{__('✓ Validation passed successfully!', 'thinkrank')}</strong>
283 <p>{__('Schema is valid and ready for deployment. See detailed results below.', 'thinkrank')}</p>
284 </div>
285 )
286 });
287 } else if (validationResult.hasWarnings && !validationResult.hasErrors) {
288 setNotice({
289 status: 'warning',
290 message: (
291 <div>
292 <strong>{__('⚠ Validation completed with warnings', 'thinkrank')}</strong>
293 <p>{__('Schema will work but could be improved. Check the warnings below for recommendations.', 'thinkrank')}</p>
294 <ul style={{ paddingLeft: '20px', marginTop: '8px' }}>
295 {validationResult.warnings.slice(0, 3).map((warning, index) => (
296 <li key={index}>{warning}</li>
297 ))}
298 {validationResult.warnings.length > 3 && (
299 <li>{__(`... and ${validationResult.warnings.length - 3} more warnings`, 'thinkrank')}</li>
300 )}
301 </ul>
302 </div>
303 )
304 });
305 } else {
306 setNotice({
307 status: 'error',
308 message: (
309 <div>
310 <strong>{__('✗ Validation failed', 'thinkrank')}</strong>
311 <p>{__('Please fix the following errors before deploying schema:', 'thinkrank')}</p>
312 <ul style={{ paddingLeft: '20px', marginTop: '8px' }}>
313 {validationResult.errors.slice(0, 3).map((error, index) => (
314 <li key={index}>{error}</li>
315 ))}
316 {validationResult.errors.length > 3 && (
317 <li>{__(`... and ${validationResult.errors.length - 3} more errors`, 'thinkrank')}</li>
318 )}
319 </ul>
320 </div>
321 )
322 });
323 }
324 } catch (error) {
325 console.error('Validation error:', error);
326 setNotice({
327 status: 'error',
328 message: __('Validation failed due to an unexpected error. Please try again.', 'thinkrank')
329 });
330 } finally {
331 updateLoadingState('isValidating', false);
332 }
333 };
334
335 /**
336 * Get validation data for specific section
337 */
338 const getSectionValidationData = (section) => {
339 switch (section) {
340 case 'schema-settings':
341 return {
342 auto_generate_schema: settings.auto_generate_schema,
343 enabled_schema_types: settings.enabled_schema_types,
344 validation_level: settings.validation_level
345 };
346 case 'organization':
347 return {
348 organization_name: settings.organization_name,
349 organization_type: settings.organization_type,
350 organization_url: settings.organization_url,
351 organization_description: settings.organization_description,
352 organization_logo: settings.organization_logo
353 };
354 case 'website':
355 return {
356 website_name: settings.website_name,
357 website_url: settings.website_url,
358 website_description: settings.website_description,
359 website_author: settings.website_author
360 };
361 case 'person':
362 return {
363 person_name: settings.person_name,
364 person_image: settings.person_image,
365 person_job_title: settings.person_job_title,
366 person_nationality: settings.person_nationality,
367 person_biography: settings.person_description, // Fixed: form uses person_description
368 person_url: settings.person_url,
369 person_email: settings.person_email,
370 person_telephone: settings.person_telephone,
371 person_birth_date: settings.person_birth_date,
372 person_address: settings.person_address,
373 person_works_for: settings.person_works_for
374 };
375 case 'local-business':
376 return {
377 business_geo_latitude: settings.business_geo_latitude,
378 business_geo_longitude: settings.business_geo_longitude,
379 business_price_range: settings.business_price_range,
380 contact_email: settings.contact_email,
381 contact_phone: settings.contact_phone,
382 contact_address: settings.contact_address,
383 business_social_facebook: settings.business_social_facebook,
384 business_social_twitter: settings.business_social_twitter,
385 business_social_linkedin: settings.business_social_linkedin,
386 business_social_instagram: settings.business_social_instagram,
387 business_social_youtube: settings.business_social_youtube,
388 // Site Identity data for logo
389 logo_url: settings.logo_url,
390 // Organization data for social media
391 organization_social_facebook: settings.organization_social_facebook,
392 organization_social_twitter: settings.organization_social_twitter,
393 organization_social_linkedin: settings.organization_social_linkedin,
394 organization_social_instagram: settings.organization_social_instagram,
395 organization_social_youtube: settings.organization_social_youtube,
396 // Organization contact hours for business hours display
397 organization_contact_hours: settings.organization_contact_hours
398 };
399 case 'schema-types':
400 return {
401 enabled_schema_types: settings.enabled_schema_types,
402 // Product schema fields
403 product_name: settings.product_name,
404 product_image: settings.product_image,
405 product_price: settings.product_price,
406 product_currency: settings.product_currency,
407 product_brand: settings.product_brand,
408 product_sku: settings.product_sku,
409 product_gtin: settings.product_gtin,
410 product_availability: settings.product_availability,
411 product_description: settings.product_description,
412 product_url: settings.product_url,
413 product_keywords: settings.product_keywords,
414 product_review: settings.product_review,
415 product_rating_value: settings.product_rating_value,
416 product_rating_count: settings.product_rating_count,
417 // Event schema fields
418 event_name: settings.event_name,
419 event_type: settings.event_type,
420 event_start_date: settings.event_start_date,
421 event_end_date: settings.event_end_date,
422 event_status: settings.event_status,
423 event_location: settings.event_location,
424 event_attendance_mode: settings.event_attendance_mode,
425 event_organizer: settings.event_organizer,
426 event_performer: settings.event_performer,
427 event_image: settings.event_image,
428 event_price: settings.event_price,
429 event_currency: settings.event_currency,
430 event_description: settings.event_description,
431 // SoftwareApplication schema fields
432 software_name: settings.software_name,
433 software_alternate_name: settings.software_alternate_name,
434 software_version: settings.software_version,
435 software_url: settings.software_url,
436 software_creator: settings.software_creator,
437 software_creator_type: settings.software_creator_type,
438 software_description: settings.software_description,
439 software_operating_systems: settings.software_operating_systems,
440 software_category: settings.software_category,
441 software_features: settings.software_features,
442 software_file_size: settings.software_file_size,
443 software_license: settings.software_license,
444 software_price: settings.software_price,
445 software_currency: settings.software_currency,
446 software_price_valid_until: settings.software_price_valid_until,
447 software_availability: settings.software_availability,
448 software_pricing_description: settings.software_pricing_description,
449 software_rating_value: settings.software_rating_value,
450 software_rating_count: settings.software_rating_count,
451 // HowTo schema fields
452 howto_name: settings.howto_name,
453 howto_description: settings.howto_description,
454 howto_image: settings.howto_image,
455 howto_steps: settings.howto_steps,
456 howto_total_time: settings.howto_total_time,
457 howto_prep_time: settings.howto_prep_time,
458 howto_difficulty: settings.howto_difficulty,
459 howto_estimated_cost: settings.howto_estimated_cost,
460 howto_supply: settings.howto_supply,
461 howto_tool: settings.howto_tool,
462 howto_url: settings.howto_url,
463 howto_yield: settings.howto_yield,
464 // Person schema data
465 person_name: settings.person_name,
466 person_job_title: settings.person_job_title,
467 person_description: settings.person_description,
468 person_image: settings.person_image,
469 person_url: settings.person_url,
470 person_email: settings.person_email,
471 person_telephone: settings.person_telephone,
472 person_address: settings.person_address,
473 person_birth_date: settings.person_birth_date,
474 person_nationality: settings.person_nationality,
475 person_works_for: settings.person_works_for,
476 person_same_as: settings.person_same_as,
477 // FAQPage schema data
478 faq_questions: settings.faq_questions,
479 faq_page_name: settings.faq_page_name,
480 faq_page_description: settings.faq_page_description,
481 faq_page_url: settings.faq_page_url
482 };
483 case 'deployment':
484 return {
485 deployment_method: settings.deployment_method,
486 schema_placement: settings.schema_placement,
487 minify_schema: settings.minify_schema,
488 validate_before_output: settings.validate_before_output,
489 fallback_on_error: settings.fallback_on_error
490 };
491 default:
492 return settings;
493 }
494 };
495
496 // Validation is now handled by the utility function
497
498
499
500
501
502 // MEMORY OPTIMIZATION: Consolidated schema data states to reduce useState hooks
503 const [schemaData, setSchemaData] = useState({
504 schemaTypes: [],
505 generatedSchemas: null,
506 deployedSchemas: null,
507 validationResults: null,
508 schemaPreview: null,
509 performanceData: null
510 });
511
512 // Helper function to update schema data safely
513 const updateSchemaData = (key, value) => {
514 if (isMountedRef.current) {
515 setSchemaData(prev => ({ ...prev, [key]: value }));
516 }
517 };
518
519 // Extract individual schema data for backward compatibility
520 const { schemaTypes, generatedSchemas, deployedSchemas, validationResults, schemaPreview, performanceData } = schemaData;
521
522
523
524
525
526 /**
527 * Test schema with Google Rich Results
528 */
529 const testWithGoogleRichResults = async () => {
530 try {
531 updateLoadingState('isValidating', true);
532 safeSetState(setNotice, null);
533
534 // Create test URL for Rich Results Test
535 const testUrl = window.location.origin;
536 const richResultsUrl = `https://search.google.com/test/rich-results?url=${encodeURIComponent(testUrl)}`;
537
538 // Open Rich Results Test in new tab
539 window.open(richResultsUrl, '_blank');
540
541 setNotice({
542 status: 'info',
543 message: __('Google Rich Results Test opened in new tab. Test your schema markup there.', 'thinkrank')
544 });
545
546 } catch (error) {
547 console.error('Rich Results test error:', error);
548 setNotice({
549 status: 'error',
550 message: __('Failed to open Rich Results Test. Please try again.', 'thinkrank')
551 });
552 } finally {
553 updateLoadingState('isValidating', false);
554 }
555 };
556
557 useEffect(() => {
558 loadSettings();
559 loadSchemaTypes();
560 loadDeployedSchemas();
561 }, []);
562
563 // MEMORY LEAK FIX: Component cleanup following Site Identity pattern
564 useEffect(() => {
565 return () => {
566 // Mark component as unmounted to prevent state updates
567 isMountedRef.current = false;
568
569 // Cancel any ongoing API requests
570 if (abortControllerRef.current) {
571 abortControllerRef.current.abort();
572 }
573
574 // Clear notices to prevent memory leaks
575 setNotice(null);
576 };
577 }, []);
578
579 // Clear validation results when switching away from schema sections
580 // Keep validation results for form validation tabs (organization, website, person)
581 useEffect(() => {
582 const formValidationTabs = ['organization', 'website', 'person', 'schema-validation'];
583 if (!formValidationTabs.includes(activeSubSection)) {
584 if (isMountedRef.current) {
585 updateSchemaData('validationResults', null);
586 }
587 }
588 }, [activeSubSection]);
589
590 /**
591 * Load deployed schemas from database
592 */
593 const loadDeployedSchemas = async () => {
594 try {
595 const response = await apiFetch({
596 path: '/thinkrank/v1/schema/deployed?context_type=site'
597 });
598
599 if (response.success) {
600 updateSchemaData('deployedSchemas', response.data);
601 }
602 } catch (error) {
603 console.error('Failed to load deployed schemas:', error);
604 }
605 };
606
607
608
609 /**
610 * Load available schema types
611 */
612 const loadSchemaTypes = async () => {
613 try {
614 const response = await apiFetch({
615 path: '/thinkrank/v1/schema/types?context=site',
616 method: 'GET'
617 });
618
619 if (response.success) {
620 updateSchemaData('schemaTypes', response.data.types || []);
621 }
622 } catch (error) {
623 console.error('Schema types load error:', error);
624 // Fallback to site-level schema types only
625 updateSchemaData('schemaTypes', [
626 { name: 'Organization', description: 'Companies and organizations' },
627 { name: 'LocalBusiness', description: 'Local businesses with physical locations' },
628 { name: 'Person', description: 'Individual person or author information' },
629 { name: 'WebSite', description: 'Website-level information and search functionality' }
630 ]);
631 }
632 };
633
634
635
636 /**
637 * Validate settings before saving (basic validation only)
638 * Note: Detailed validation is handled by runValidation() function
639 */
640 const validateSettings = () => {
641 const errors = [];
642
643 // Only validate critical configuration issues, not content fields
644 // Validate deployment method if set
645 const validDeploymentMethods = ['json_ld', 'microdata', 'rdfa'];
646 if (settings.deployment_method && !validDeploymentMethods.includes(settings.deployment_method)) {
647 errors.push(__('Invalid deployment method selected', 'thinkrank'));
648 }
649
650 // Only validate URL formats if URLs are provided (not required for save)
651 if (settings.organization_url && !sharedIsValidUrl(settings.organization_url)) {
652 errors.push(__('Organization URL must be a valid URL format', 'thinkrank'));
653 }
654 if (settings.website_url && !sharedIsValidUrl(settings.website_url)) {
655 errors.push(__('Website URL must be a valid URL format', 'thinkrank'));
656 }
657
658 // Handle MediaPicker value for logo URL validation
659 const logoUrl = typeof settings.organization_logo === 'object'
660 ? settings.organization_logo?.url
661 : settings.organization_logo;
662 if (logoUrl && !sharedIsValidUrl(logoUrl)) {
663 errors.push(__('Organization Logo URL must be a valid URL format', 'thinkrank'));
664 }
665
666 return errors;
667 };
668
669
670
671 /**
672 * Handle schema type toggle
673 */
674 const handleSchemaTypeToggle = (schemaType, enabled) => {
675 const currentTypes = settings.enabled_schema_types || [];
676 let newTypes;
677
678 if (enabled) {
679 newTypes = [...currentTypes, schemaType];
680 } else {
681 newTypes = currentTypes.filter(type => type !== schemaType);
682 }
683
684 handleSettingChange('enabled_schema_types', newTypes);
685 };
686
687
688
689 /**
690 * Sync data from Site Identity
691 */
692 const syncFromSiteIdentity = async (syncType) => {
693 try {
694 updateLoadingState('isSyncing', true);
695 safeSetState(setNotice, null);
696
697 const response = await apiFetch({
698 path: '/thinkrank/v1/site-identity/settings',
699 method: 'GET'
700 });
701
702 if (response.success) {
703 const siteIdentityData = response.data.settings;
704 const syncedData = mapSiteIdentityData(siteIdentityData, syncType);
705
706 // Update settings with synced data
707 const updatedSettings = { ...settings, ...syncedData };
708 setSettings(updatedSettings);
709
710 setNotice({
711 status: 'success',
712 message: __(`${getSyncTypeDisplayName(syncType)} data synced successfully from Site Identity!`, 'thinkrank')
713 });
714 } else {
715 throw new Error('Failed to fetch Site Identity data');
716 }
717 } catch (error) {
718 console.error('Sync error:', error);
719 setNotice({
720 status: 'error',
721 message: __(`Failed to sync ${getSyncTypeDisplayName(syncType)} data. Please try again.`, 'thinkrank')
722 });
723 } finally {
724 updateLoadingState('isSyncing', false);
725 }
726 };
727
728 /**
729 * Sync contact hours from Local SEO
730 */
731 const syncFromLocalSEO = async (syncType) => {
732 try {
733 updateLoadingState('isSyncing', true);
734 safeSetState(setNotice, null);
735
736 const response = await apiFetch({
737 path: '/thinkrank/v1/site-identity/settings',
738 method: 'GET'
739 });
740
741 if (response.success) {
742 const localSeoData = response.data.settings;
743
744 if (syncType === 'contact_hours') {
745 // Format business hours for contact hours
746 const businessHours = localSeoData.business_hours || {};
747 const formattedHours = formatBusinessHoursForContact(businessHours);
748
749 const updatedSettings = { ...settings, organization_contact_hours: formattedHours };
750 setSettings(updatedSettings);
751 setHasChanges(true);
752
753 setNotice({
754 status: 'success',
755 message: __('Contact hours synced successfully from Local SEO business hours!', 'thinkrank')
756 });
757 }
758 } else {
759 throw new Error('Failed to fetch Local SEO data');
760 }
761 } catch (error) {
762 console.error('Local SEO sync error:', error);
763 setNotice({
764 status: 'error',
765 message: __('Failed to sync contact hours from Local SEO. Please try again.', 'thinkrank')
766 });
767 } finally {
768 updateLoadingState('isSyncing', false);
769 }
770 };
771
772 /**
773 * Format business hours from Local SEO for contact hours display
774 */
775 const formatBusinessHoursForContact = (businessHours) => {
776 if (!businessHours || typeof businessHours !== 'object') {
777 return __('No business hours configured in Local SEO', 'thinkrank');
778 }
779
780 const dayNames = {
781 monday: __('Mo', 'thinkrank'),
782 tuesday: __('Tu', 'thinkrank'),
783 wednesday: __('We', 'thinkrank'),
784 thursday: __('Th', 'thinkrank'),
785 friday: __('Fr', 'thinkrank'),
786 saturday: __('Sa', 'thinkrank'),
787 sunday: __('Su', 'thinkrank')
788 };
789
790 const formattedDays = [];
791 Object.keys(dayNames).forEach(day => {
792 const dayData = businessHours[day];
793 if (dayData && !dayData.closed && dayData.open && dayData.close) {
794 formattedDays.push(`${dayNames[day]} ${dayData.open}-${dayData.close}`);
795 }
796 });
797
798 return formattedDays.length > 0
799 ? formattedDays.join(', ')
800 : __('No business hours configured in Local SEO', 'thinkrank');
801 };
802
803 /**
804 * Map Site Identity data to Schema Manager fields
805 */
806 const mapSiteIdentityData = (siteIdentityData, syncType) => {
807 const mappings = {
808 website: {
809 website_name: siteIdentityData.site_name || '',
810 website_url: siteIdentityData.site_url || window.location.origin,
811 website_description: siteIdentityData.site_description || siteIdentityData.default_meta_description || '',
812 website_author: siteIdentityData.site_author || ''
813 },
814 organization: {
815 organization_name: siteIdentityData.site_name || '',
816 organization_url: siteIdentityData.site_url || window.location.origin,
817 organization_description: siteIdentityData.site_description || siteIdentityData.default_meta_description || '',
818 // MediaPicker expects URL string, Site Identity provides URL string
819 organization_logo: siteIdentityData.logo_url || ''
820 }
821 };
822
823 return mappings[syncType] || {};
824 };
825
826 /**
827 * Get display name for sync type
828 */
829 const getSyncTypeDisplayName = (syncType) => {
830 const displayNames = {
831 website: __('Website', 'thinkrank'),
832 organization: __('Organization', 'thinkrank')
833 };
834 return displayNames[syncType] || syncType;
835 };
836
837 /**
838 * Generate schema markup
839 */
840 const generateSchema = async () => {
841 try {
842 updateLoadingState('isGenerating', true);
843
844 // Valid site-level schema types (post/page schemas handled by metabox)
845 const validSchemaTypes = [
846 'Organization', 'LocalBusiness', 'Person', 'WebSite'
847 ];
848
849 // Filter and validate schema types
850 let schemaTypes = settings.enabled_schema_types || [];
851 if (Array.isArray(schemaTypes)) {
852 schemaTypes = schemaTypes.filter(type => validSchemaTypes.includes(type));
853 }
854
855 // Use fallback if no valid types
856 if (!schemaTypes.length) {
857 schemaTypes = ['Organization'];
858 }
859
860 // Prepare request data - omit context_id for site context
861 const requestData = {
862 context_type: 'site',
863 schema_types: schemaTypes,
864 options: {
865 validation_level: settings.validation_level || 'moderate',
866 rich_snippets_optimization: settings.rich_snippets_optimization || true,
867 knowledge_graph: settings.knowledge_graph || true,
868 auto_generate_schema: settings.auto_generate_schema || true,
869 enable_breadcrumbs_schema: settings.enable_breadcrumbs_schema || false,
870 enable_local_business: settings.enable_local_business || false
871 }
872 };
873
874 const response = await apiFetch({
875 path: '/thinkrank/v1/schema/generate',
876 method: 'POST',
877 data: requestData
878 });
879
880 if (response.success) {
881 updateSchemaData('generatedSchemas', response.data);
882 safeSetState(setNotice, {
883 status: 'success',
884 message: __('Schema markup generated successfully!', 'thinkrank')
885 });
886 }
887 } catch (error) {
888 console.error('Schema generation error:', error);
889 setNotice({
890 status: 'error',
891 message: __('Schema generation failed. Please try again.', 'thinkrank')
892 });
893 } finally {
894 updateLoadingState('isGenerating', false);
895 }
896 };
897
898 /**
899 * Validate all schema markup
900 */
901 const validateAllSchemas = async () => {
902 try {
903 updateLoadingState('isValidating', true);
904
905 if (!generatedSchemas?.generated_schemas) {
906 setNotice({
907 status: 'error',
908 message: __('No schema markup to validate. Please generate schema first.', 'thinkrank')
909 });
910 return;
911 }
912
913 // Validate each schema type separately (API expects single schema)
914 const validationResults = {};
915 const schemas = generatedSchemas.generated_schemas;
916
917 for (const [schemaType, schemaData] of Object.entries(schemas)) {
918 try {
919 const response = await apiFetch({
920 path: '/thinkrank/v1/schema/validate',
921 method: 'POST',
922 data: {
923 schema_data: schemaData,
924 schema_type: schemaType,
925 options: {
926 validation_level: settings.validation_level || 'moderate'
927 }
928 }
929 });
930
931 if (response.success) {
932 validationResults[schemaType] = response.data;
933 }
934 } catch (error) {
935 validationResults[schemaType] = {
936 valid: false,
937 errors: [error.message || 'Validation failed']
938 };
939 }
940 }
941
942 updateSchemaData('validationResults', {
943 validation_details: validationResults,
944 overall_status: Object.values(validationResults).every(result => result.valid !== false) ? 'valid' : 'invalid'
945 });
946
947 setNotice({
948 status: 'success',
949 message: __('Schema validation completed!', 'thinkrank')
950 });
951 } catch (error) {
952 console.error('Schema validation error:', error);
953 setNotice({
954 status: 'error',
955 message: __('Schema validation failed. Please try again.', 'thinkrank')
956 });
957 } finally {
958 updateLoadingState('isValidating', false);
959 }
960 };
961
962 /**
963 * Validate individual schema type
964 */
965 const validateSingleSchema = async (schemaType) => {
966 try {
967 updateLoadingState('isValidating', true);
968
969 if (!generatedSchemas?.generated_schemas?.[schemaType]) {
970 setNotice({
971 status: 'error',
972 message: __(`No ${schemaType} schema to validate. Please generate schema first.`, 'thinkrank')
973 });
974 return;
975 }
976
977 const response = await apiFetch({
978 path: '/thinkrank/v1/schema/validate',
979 method: 'POST',
980 data: {
981 schema_data: generatedSchemas.generated_schemas[schemaType],
982 schema_type: schemaType,
983 options: {
984 validation_level: settings.validation_level || 'moderate'
985 }
986 }
987 });
988
989 if (response.success) {
990 // Update validation results for this specific schema type
991 const newValidationResults = {
992 ...validationResults,
993 validation_details: {
994 ...validationResults?.validation_details,
995 [schemaType]: response.data
996 }
997 };
998
999 updateSchemaData('validationResults', newValidationResults);
1000
1001 setNotice({
1002 status: 'success',
1003 message: __(`${schemaType} schema validation completed!`, 'thinkrank')
1004 });
1005 }
1006 } catch (error) {
1007 console.error(`${schemaType} schema validation error:`, error);
1008
1009 // Update validation results with error
1010 updateSchemaData('validationResults', prev => ({
1011 ...prev,
1012 validation_details: {
1013 ...prev?.validation_details,
1014 [schemaType]: {
1015 valid: false,
1016 errors: [error.message || 'Validation failed']
1017 }
1018 }
1019 }));
1020
1021 setNotice({
1022 status: 'error',
1023 message: __(`${schemaType} schema validation failed. Please try again.`, 'thinkrank')
1024 });
1025 } finally {
1026 updateLoadingState('isValidating', false);
1027 }
1028 };
1029
1030 /**
1031 * Deploy schema markup
1032 */
1033 const deploySchema = async () => {
1034 try {
1035 updateLoadingState('isDeploying', true);
1036
1037 // Use generated schemas if available, otherwise use deployed schemas for redeployment
1038 const schemasTodeploy = generatedSchemas?.generated_schemas || deployedSchemas;
1039
1040 if (!schemasTodeploy) {
1041 setNotice({
1042 status: 'error',
1043 message: __('No schema markup to deploy. Please generate schema first.', 'thinkrank')
1044 });
1045 return;
1046 }
1047
1048 // Prepare deployment data - omit context_id for site context
1049 const requestData = {
1050 context_type: 'site',
1051 schema_data: schemasTodeploy,
1052 options: {
1053 deployment_method: settings.deployment_method || 'json_ld'
1054 }
1055 };
1056
1057 const response = await apiFetch({
1058 path: '/thinkrank/v1/schema/deploy',
1059 method: 'POST',
1060 data: requestData
1061 });
1062
1063 if (response.success) {
1064 setNotice({
1065 status: 'success',
1066 message: __('Schema markup deployed successfully!', 'thinkrank')
1067 });
1068
1069 // Refresh deployed schemas after successful deployment
1070 loadDeployedSchemas();
1071 }
1072 } catch (error) {
1073 console.error('Schema deployment error:', error);
1074 setNotice({
1075 status: 'error',
1076 message: __('Schema deployment failed. Please try again.', 'thinkrank')
1077 });
1078 } finally {
1079 updateLoadingState('isDeploying', false);
1080 }
1081 };
1082
1083 /**
1084 * Generate schema preview
1085 */
1086 const generatePreview = async () => {
1087 try {
1088 if (!generatedSchemas?.generated_schemas) {
1089 setNotice({
1090 status: 'error',
1091 message: __('No schema markup to preview. Please generate schema first.', 'thinkrank')
1092 });
1093 return;
1094 }
1095
1096 // Generate preview for each schema type
1097 const previewResults = {};
1098 const schemas = generatedSchemas.generated_schemas;
1099
1100 for (const [schemaType, schemaData] of Object.entries(schemas)) {
1101 try {
1102 const response = await apiFetch({
1103 path: '/thinkrank/v1/schema/preview',
1104 method: 'POST',
1105 data: {
1106 schema_data: schemaData,
1107 schema_type: schemaType
1108 }
1109 });
1110
1111 if (response.success && response.data.rich_snippets) {
1112 // Preserve schema type structure in preview results
1113 previewResults[schemaType] = response.data.rich_snippets;
1114 }
1115 } catch (error) {
1116 console.error(`Schema preview error for ${schemaType}:`, error);
1117 }
1118 }
1119
1120 updateSchemaData('schemaPreview', {
1121 rich_snippets: previewResults
1122 });
1123 } catch (error) {
1124 console.error('Schema preview error:', error);
1125 }
1126 };
1127
1128
1129
1130 /**
1131 * Get validation level options (uses shared configuration)
1132 */
1133 const getValidationLevelOptions = getSharedValidationLevelOptions;
1134
1135 /**
1136 * Get organization type options
1137 */
1138 const getOrganizationTypeOptions = () => [
1139 { label: __('Organization', 'thinkrank'), value: 'Organization' },
1140 { label: __('Corporation', 'thinkrank'), value: 'Corporation' },
1141 { label: __('Local Business', 'thinkrank'), value: 'LocalBusiness' },
1142 { label: __('Non-Profit', 'thinkrank'), value: 'NGO' },
1143 { label: __('Educational Organization', 'thinkrank'), value: 'EducationalOrganization' },
1144 { label: __('Government Organization', 'thinkrank'), value: 'GovernmentOrganization' }
1145 ];
1146
1147 /**
1148 * Get optimization button configuration based on active sub-section
1149 */
1150 const getOptimizationButtonConfig = () => {
1151 const buttons = [];
1152
1153 // Section-specific buttons
1154 switch (activeSubSection) {
1155 case 'website':
1156 buttons.push(
1157 <OptimizationButton
1158 key="sync-website"
1159 type="rule"
1160 label={__('Sync from Site Identity', 'thinkrank')}
1161 onClick={() => syncFromSiteIdentity('website')}
1162 isBusy={isSyncing}
1163 />
1164 );
1165 break;
1166
1167 case 'organization':
1168 buttons.push(
1169 <OptimizationButton
1170 key="sync-organization"
1171 type="rule"
1172 label={__('Sync from Site Identity', 'thinkrank')}
1173 onClick={() => syncFromSiteIdentity('organization')}
1174 isBusy={isSyncing}
1175 />
1176 );
1177 break;
1178
1179 case 'deployment':
1180 // Enhanced deployment button logic based on deployment status
1181 const hasGenerated = generatedSchemas?.generated_schemas;
1182 const hasDeployed = deployedSchemas && Object.keys(deployedSchemas).length > 0;
1183
1184 let deployButtonLabel, deployButtonAction, deployButtonTitle;
1185
1186 if (hasDeployed && hasGenerated) {
1187 // Has both deployed and new generated schemas - ready to redeploy
1188 deployButtonLabel = __('Redeploy Schema', 'thinkrank');
1189 deployButtonAction = deploySchema;
1190 deployButtonTitle = __('Deploy updated schema markup to replace current version', 'thinkrank');
1191 } else if (hasDeployed) {
1192 // Has deployed schemas but no new generated ones - regenerate
1193 deployButtonLabel = __('Regenerate Schema', 'thinkrank');
1194 deployButtonAction = generateSchema;
1195 deployButtonTitle = __('Generate new schema markup to update deployment', 'thinkrank');
1196 } else if (hasGenerated) {
1197 // Has generated schemas but not deployed - deploy
1198 deployButtonLabel = __('Deploy Schema', 'thinkrank');
1199 deployButtonAction = deploySchema;
1200 deployButtonTitle = __('Deploy generated schema markup to your website', 'thinkrank');
1201 } else {
1202 // No schemas at all - generate first
1203 deployButtonLabel = __('Generate Schema First', 'thinkrank');
1204 deployButtonAction = generateSchema;
1205 deployButtonTitle = __('Generate schema markup first, then deploy', 'thinkrank');
1206 }
1207
1208 buttons.push(
1209 <OptimizationButton
1210 key="deploy"
1211 type="generate"
1212 label={deployButtonLabel}
1213 onClick={deployButtonAction}
1214 isBusy={isDeploying || isGenerating}
1215 title={deployButtonTitle}
1216 />
1217 );
1218 break;
1219 }
1220
1221 // Validation button only for data tabs (not configuration tabs or informational tabs)
1222 if (['organization', 'website', 'person'].includes(activeSubSection)) {
1223 buttons.push(
1224 <OptimizationButton
1225 key="validate"
1226 type="validate"
1227 onClick={() => runValidation()}
1228 isBusy={isValidating}
1229 />
1230 );
1231 }
1232
1233 // Rich Results test button for schema sections
1234 if (['organization', 'website', 'person'].includes(activeSubSection)) {
1235 buttons.push(
1236 <OptimizationButton
1237 key="rich-results-test"
1238 type="test"
1239 label={__('Test Rich Results', 'thinkrank')}
1240 onClick={testWithGoogleRichResults}
1241 isBusy={isValidating}
1242 />
1243 );
1244 }
1245
1246 return buttons;
1247 };
1248
1249 if (isLoading) {
1250 return (
1251 <div className="thinkrank-loading">
1252 <Spinner />
1253 <p>{__('Loading schema settings...', 'thinkrank')}</p>
1254 </div>
1255 );
1256 }
1257
1258 return (
1259 <div className="thinkrank-schema-tab">
1260 <SettingsCard
1261 title={__('Site Schema Manager', 'thinkrank')}
1262 optimizationButtons={getOptimizationButtonConfig()}
1263 notice={notice}
1264 onNoticeRemove={() => setNotice(null)}
1265 >
1266
1267 {/* Content based on active subsection */}
1268 {(() => {
1269 // Map activeSubSection to tab names for compatibility
1270 const sectionMap = {
1271 'schema-settings': 'settings',
1272 'schema-types': 'types',
1273 'organization': 'organization',
1274 'website': 'website',
1275 'person': 'person',
1276 'local-business': 'local-business',
1277 'deployment': 'deployment'
1278 };
1279
1280 const tabName = sectionMap[activeSubSection] || 'settings';
1281
1282 switch (tabName) {
1283 case 'settings':
1284 return (
1285 <SchemaSettings
1286 settings={settings}
1287 onSettingChange={handleSettingChange}
1288 isLoading={isLoading}
1289 />
1290 );
1291
1292 case 'types':
1293 return (
1294 <SchemaGeneration
1295 settings={settings}
1296 onSettingChange={handleSettingChange}
1297 isLoading={isLoading}
1298 />
1299 );
1300
1301 case 'organization':
1302 return (
1303 <SchemaOrganizationForm
1304 settings={settings}
1305 onSettingChange={handleSettingChange}
1306 isLoading={isLoading}
1307 />
1308 );
1309
1310 case 'website':
1311 return (
1312 <SchemaWebsiteForm
1313 settings={settings}
1314 onSettingChange={handleSettingChange}
1315 isLoading={isLoading}
1316 />
1317 );
1318
1319 case 'person':
1320 return (
1321 <SchemaPersonForm
1322 settings={settings}
1323 onSettingChange={handleSettingChange}
1324 isLoading={isLoading}
1325 />
1326 );
1327
1328 case 'local-business':
1329 return (
1330 <SchemaLocalBusinessForm
1331 settings={settings}
1332 onSettingChange={handleSettingChange}
1333 isLoading={isLoading}
1334 onNavigate={onNavigate}
1335 />
1336 );
1337
1338 case 'deployment':
1339 return (
1340 <SchemaDeployment
1341 generatedSchemas={generatedSchemas}
1342 deployedSchemas={deployedSchemas}
1343 isDeploying={isDeploying}
1344 onDeploySchema={deploySchema}
1345 onGenerateSchema={generateSchema}
1346 settings={settings}
1347 onSettingChange={handleSettingChange}
1348 />
1349 );
1350
1351 default:
1352 return null;
1353 }
1354 })()}
1355
1356 {/* Schema Preview Component */}
1357 <SchemaPreview
1358 generatedSchemas={generatedSchemas}
1359 schemaPreview={schemaPreview}
1360 onGeneratePreview={generatePreview}
1361 />
1362
1363 {/* Schema Validation Component */}
1364 <SchemaValidation
1365 generatedSchemas={generatedSchemas}
1366 validationResults={validationResults}
1367 isValidating={isValidating}
1368 onValidateSchema={validateSingleSchema}
1369 onValidateAllSchemas={validateAllSchemas}
1370 settings={settings}
1371 formValidationResults={validationResults}
1372 onRunFormValidation={runValidation}
1373 activeSubSection={activeSubSection}
1374 />
1375
1376
1377
1378 {/* Save Button */}
1379 <Flex justify="flex-end" className="thinkrank-mt-lg">
1380 <FlexItem>
1381 <Button
1382 variant="primary"
1383 onClick={saveSettings}
1384 isBusy={isSaving}
1385 disabled={!hasChanges || isSaving}
1386 >
1387 {isSaving ? __('Saving...', 'thinkrank') : __('Save Settings', 'thinkrank')}
1388 </Button>
1389 </FlexItem>
1390 </Flex>
1391 </SettingsCard>
1392 </div>
1393 );
1394 };
1395
1396 export default SchemaTab;
1397