| 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 |
|