PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.15.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.15.0
2.15.0 2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 All 59 releases
thinkrank / src / admin / components / essential-seo / schema / forms / SchemaOrganizationForm.js

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

592 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Schema Organization Form Component
3 *
4 * Redesigned — uses plugin UI components, Tailwind classes, inline SVG icons.
5 *
6 * @package
7 * @since 1.0.0
8 */
9
10 import { __, sprintf } from '@wordpress/i18n';
11 import { Badge, Card, Input, Textarea, Select } from '../../../ui';
12 import MediaPicker from '../../../common/MediaPicker';
13 import SchemaDisabledNotice from '../SchemaDisabledNotice';
14 import {
15 getOrganizationTypeOptions,
16 getContactTypeOptions,
17 } from '../../../../config/schema-settings-config';
18
19 /* ── Icons ──────────────────────────────────────────────────────────────── */
20
21 const IconInfo = () => (
22 <svg
23 width="15"
24 height="15"
25 viewBox="0 0 24 24"
26 fill="none"
27 stroke="currentColor"
28 strokeWidth="2"
29 strokeLinecap="round"
30 strokeLinejoin="round"
31 >
32 <circle cx="12" cy="12" r="10" />
33 <line x1="12" y1="16" x2="12" y2="12" />
34 <line x1="12" y1="8" x2="12.01" y2="8" />
35 </svg>
36 );
37
38 const IconArrow = () => (
39 <svg
40 width="13"
41 height="13"
42 viewBox="0 0 24 24"
43 fill="none"
44 stroke="currentColor"
45 strokeWidth="2"
46 strokeLinecap="round"
47 strokeLinejoin="round"
48 >
49 <line x1="5" y1="12" x2="19" y2="12" />
50 <polyline points="12 5 19 12 12 19" />
51 </svg>
52 );
53
54 const IconFacebook = () => (
55 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
56 <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
57 </svg>
58 );
59
60 const IconTwitter = () => (
61 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
62 <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
63 </svg>
64 );
65
66 const IconLinkedIn = () => (
67 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
68 <path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
69 <rect x="2" y="9" width="4" height="12" />
70 <circle cx="4" cy="4" r="2" />
71 </svg>
72 );
73
74 const IconInstagram = () => (
75 <svg
76 width="16"
77 height="16"
78 viewBox="0 0 24 24"
79 fill="none"
80 stroke="currentColor"
81 strokeWidth="2"
82 strokeLinecap="round"
83 strokeLinejoin="round"
84 >
85 <rect x="2" y="2" width="20" height="20" rx="5" />
86 <path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
87 <line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
88 </svg>
89 );
90
91 const IconYouTube = () => (
92 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
93 <path d="M22.54 6.42a2.78 2.78 0 0 0-1.95-1.96C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 0 0-1.95 1.96A29 29 0 0 0 1 12a29 29 0 0 0 .46 5.58A2.78 2.78 0 0 0 3.41 19.6C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 0 0 1.95-1.95A29 29 0 0 0 23 12a29 29 0 0 0-.46-5.58z" />
94 <polygon fill="#fff" points="9.75 15.02 15.5 12 9.75 8.98 9.75 15.02" />
95 </svg>
96 );
97
98 const IconClock = () => (
99 <svg
100 width="15"
101 height="15"
102 viewBox="0 0 24 24"
103 fill="none"
104 stroke="currentColor"
105 strokeWidth="2"
106 strokeLinecap="round"
107 strokeLinejoin="round"
108 >
109 <circle cx="12" cy="12" r="10" />
110 <polyline points="12 6 12 12 16 14" />
111 </svg>
112 );
113
114 const IconPhone = () => (
115 <svg
116 width="14"
117 height="14"
118 viewBox="0 0 24 24"
119 fill="none"
120 stroke="currentColor"
121 strokeWidth="2"
122 strokeLinecap="round"
123 strokeLinejoin="round"
124 >
125 <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" />
126 </svg>
127 );
128
129 const IconMail = () => (
130 <svg
131 width="14"
132 height="14"
133 viewBox="0 0 24 24"
134 fill="none"
135 stroke="currentColor"
136 strokeWidth="2"
137 strokeLinecap="round"
138 strokeLinejoin="round"
139 >
140 <rect x="2" y="4" width="20" height="16" rx="2" />
141 <polyline points="22,6 12,13 2,6" />
142 </svg>
143 );
144
145 /* ── Validation ─────────────────────────────────────────────────────────── */
146
147 /**
148 * Whether a string is a complete http(s) URL.
149 *
150 * Social profiles become `sameAs` values in the emitted schema, where a bare
151 * handle or a stray sentence is not a valid reference — Google needs a
152 * resolvable URL.
153 *
154 * @param {string} value Candidate URL.
155 * @return {boolean} True when the value parses as an http(s) URL with a host.
156 */
157 const isHttpUrl = (value) => {
158 try {
159 const url = new URL(value.trim());
160 return (
161 (url.protocol === 'http:' || url.protocol === 'https:') &&
162 !!url.hostname &&
163 url.hostname.includes('.')
164 );
165 } catch (e) {
166 return false;
167 }
168 };
169
170 /**
171 * Error for a social profile field, if any. Empty is allowed — the field is
172 * optional, and only a filled-in bad value is worth flagging.
173 *
174 * @param {string} value Field value.
175 * @return {string} Error message, or an empty string.
176 */
177 const socialUrlError = (value) => {
178 const trimmed = (value || '').trim();
179 if (!trimmed || isHttpUrl(trimmed)) {
180 return '';
181 }
182 return __(
183 'Enter the full profile URL, starting with https://',
184 'thinkrank'
185 );
186 };
187
188 /**
189 * Error for the contact email field, if any.
190 *
191 * @param {string} value Field value.
192 * @return {string} Error message, or an empty string.
193 */
194 const emailError = (value) => {
195 const trimmed = (value || '').trim();
196 if (!trimmed || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
197 return '';
198 }
199 return __('Enter a valid email address.', 'thinkrank');
200 };
201
202 /**
203 * Error for the contact phone field, if any.
204 *
205 * Deliberately permissive: international numbers vary too much to pattern
206 * match, so this only rejects values that cannot be a phone number at all.
207 *
208 * @param {string} value Field value.
209 * @return {string} Error message, or an empty string.
210 */
211 const phoneError = (value) => {
212 const trimmed = (value || '').trim();
213 if (!trimmed) {
214 return '';
215 }
216 const digits = trimmed.replace(/\D/g, '');
217 if (/[a-zA-Z]/.test(trimmed) || digits.length < 7) {
218 return __('Enter a valid phone number.', 'thinkrank');
219 }
220 return '';
221 };
222
223 /* ── Social row ─────────────────────────────────────────────────────────── */
224
225 /**
226 * Platforms rendered in the Social Media Profiles card.
227 *
228 * `tint`/`color` are per-brand and data-driven, so they stay inline rather
229 * than becoming Tailwind tokens.
230 */
231 const SOCIAL_PLATFORMS = [
232 {
233 key: 'organization_social_facebook',
234 label: __('Facebook', 'thinkrank'),
235 Icon: IconFacebook,
236 color: '#1877F2',
237 tint: '#E7F0FE',
238 placeholder: 'https://facebook.com/yourpage',
239 },
240 {
241 key: 'organization_social_twitter',
242 label: __('Twitter / X', 'thinkrank'),
243 Icon: IconTwitter,
244 color: '#1D9BF0',
245 tint: '#E8F5FE',
246 placeholder: 'https://twitter.com/yourhandle',
247 },
248 {
249 key: 'organization_social_linkedin',
250 label: __('LinkedIn', 'thinkrank'),
251 Icon: IconLinkedIn,
252 color: '#0A66C2',
253 tint: '#E7F0F9',
254 placeholder: 'https://linkedin.com/company/yourcompany',
255 },
256 {
257 key: 'organization_social_instagram',
258 label: __('Instagram', 'thinkrank'),
259 Icon: IconInstagram,
260 color: '#E4405F',
261 tint: '#FDECEF',
262 placeholder: 'https://instagram.com/yourhandle',
263 },
264 {
265 key: 'organization_social_youtube',
266 label: __('YouTube', 'thinkrank'),
267 Icon: IconYouTube,
268 color: '#FF0000',
269 tint: '#FFEAEA',
270 placeholder: 'https://youtube.com/c/yourchannel',
271 },
272 ];
273
274 const SocialRow = ({
275 Icon,
276 label,
277 color,
278 tint,
279 value,
280 settingKey,
281 onSettingChange,
282 disabled,
283 placeholder,
284 }) => {
285 const error = socialUrlError(value);
286
287 return (
288 // items-start, not items-center: the chip must stay put when an error
289 // line appears under the input and grows the row.
290 <div className="tr-flex tr-items-start tr-gap-3">
291 <span
292 className="tr-flex tr-items-center tr-justify-center tr-w-8 tr-h-8 tr-mt-1 tr-rounded-lg tr-shrink-0"
293 style={{
294 backgroundColor: tint,
295 color: disabled ? '#A9AEC0' : color,
296 }}
297 >
298 <Icon />
299 </span>
300 <Input
301 id={settingKey}
302 className="tr-flex-1"
303 type="url"
304 // No visible label — the brand icon names the platform. Screen
305 // readers still need one, and the placeholder is not a label.
306 aria-label={label}
307 value={value || ''}
308 onChange={(e) => onSettingChange(settingKey, e.target.value)}
309 placeholder={placeholder}
310 disabled={disabled}
311 error={error}
312 aria-invalid={error ? 'true' : undefined}
313 />
314 </div>
315 );
316 };
317
318 /* ── Main component ─────────────────────────────────────────────────────── */
319
320 const SchemaOrganizationForm = ({
321 settings,
322 onSettingChange,
323 isLoading,
324 onNavigate,
325 }) => {
326 const disabled = !settings.enabled;
327
328 const socialFilledCount = SOCIAL_PLATFORMS.filter((platform) =>
329 (settings[platform.key] || '').trim()
330 ).length;
331
332 return (
333 <div className="tr-flex tr-flex-col tr-gap-4">
334 {disabled && <SchemaDisabledNotice onNavigate={onNavigate} />}
335 {/* ── Identity ─────────────────────────────────────────────── */}
336 <Card padding={false}>
337 <div className="tr-px-5 tr-py-4 tr-border-b tr-border-secondary">
338 <h3 className="tr-text-sm tr-font-semibold tr-text-primary tr-m-0">
339 {__('Organization Identity', 'thinkrank')}
340 </h3>
341 <p className="tr-text-xs tr-text-tertiary tr-m-0 tr-mt-0.5">
342 {__(
343 'Core details used in Organization schema markup',
344 'thinkrank'
345 )}
346 </p>
347 </div>
348 <div className="tr-p-5 tr-flex tr-flex-col tr-gap-4">
349 <Select
350 label={__('Organization Type', 'thinkrank')}
351 value={settings.organization_type || ''}
352 options={getOrganizationTypeOptions()}
353 onChange={(e) =>
354 onSettingChange('organization_type', e.target.value)
355 }
356 description={__(
357 'Type of organization for schema markup',
358 'thinkrank'
359 )}
360 disabled={disabled}
361 />
362 <Input
363 label={__('Organization Name', 'thinkrank')}
364 value={settings.organization_name || ''}
365 onChange={(e) =>
366 onSettingChange('organization_name', e.target.value)
367 }
368 description={__(
369 'Official name of your organization',
370 'thinkrank'
371 )}
372 disabled={disabled}
373 />
374 <Input
375 label={__('Organization URL', 'thinkrank')}
376 value={settings.organization_url || ''}
377 onChange={(e) =>
378 onSettingChange('organization_url', e.target.value)
379 }
380 description={__(
381 'Official website URL of your organization',
382 'thinkrank'
383 )}
384 placeholder="https://example.com"
385 disabled={disabled}
386 />
387 <Textarea
388 label={__('Organization Description', 'thinkrank')}
389 value={settings.organization_description || ''}
390 onChange={(e) =>
391 onSettingChange(
392 'organization_description',
393 e.target.value
394 )
395 }
396 description={__(
397 'Brief description of your organization',
398 'thinkrank'
399 )}
400 rows={3}
401 disabled={disabled}
402 />
403 <MediaPicker
404 label={__('Organization Logo', 'thinkrank')}
405 value={settings.organization_logo}
406 onChange={(value) =>
407 onSettingChange('organization_logo', value)
408 }
409 help={__(
410 'Logo image used in Organization schema markup',
411 'thinkrank'
412 )}
413 allowedTypes={['image']}
414 disabled={disabled}
415 />
416
417 {/* Address managed elsewhere notice */}
418 <div className="tr-flex tr-items-start tr-gap-2 tr-bg-brand-bg tr-border tr-border-brand/30 tr-rounded-lg tr-p-3">
419 <span className="tr-text-brand tr-shrink-0 tr-mt-0.5">
420 <IconInfo />
421 </span>
422 <div className="tr-flex tr-items-center tr-justify-between tr-flex-1 tr-gap-3">
423 <p className="tr-text-xs tr-text-brand tr-m-0">
424 {__(
425 'Business address, phone, and email are managed in Site Identity › Business Info for consistency across all local SEO features.',
426 'thinkrank'
427 )}
428 </p>
429 {/* Only rendered when navigation is actually wired.
430 It used to render unconditionally and no-op if
431 the prop was missing, so a forgotten wire looked
432 exactly like a working button. */}
433 {typeof onNavigate === 'function' && (
434 <button
435 type="button"
436 onClick={() =>
437 onNavigate('site-identity', 'local-seo')
438 }
439 className="tr-flex tr-items-center tr-gap-1 tr-text-xs tr-font-medium tr-text-brand tr-bg-white tr-border tr-border-brand tr-rounded tr-px-2 tr-py-1 hover:tr-bg-brand-bg tr-transition-colors tr-duration-150 tr-cursor-pointer tr-whitespace-nowrap tr-shrink-0"
440 >
441 {__('Go there', 'thinkrank')} <IconArrow />
442 </button>
443 )}
444 </div>
445 </div>
446 </div>
447 </Card>
448
449 {/* ── Social Media ─────────────────────────────────────────── */}
450 <Card padding={false}>
451 <div className="tr-px-5 tr-py-4 tr-border-b tr-border-secondary tr-flex tr-items-start tr-justify-between tr-gap-3">
452 <div>
453 <h3 className="tr-text-sm tr-font-semibold tr-text-primary tr-m-0">
454 {__('Social Media Profiles', 'thinkrank')}
455 </h3>
456 <p className="tr-text-xs tr-text-tertiary tr-m-0 tr-mt-0.5">
457 {__(
458 'Used as sameAs properties in Organization schema',
459 'thinkrank'
460 )}
461 </p>
462 </div>
463 <Badge
464 variant={socialFilledCount > 0 ? 'info' : 'neutral'}
465 className="tr-shrink-0"
466 >
467 {sprintf(
468 /* translators: 1: profiles filled in, 2: profiles available. */
469 __('%1$d of %2$d added', 'thinkrank'),
470 socialFilledCount,
471 SOCIAL_PLATFORMS.length
472 )}
473 </Badge>
474 </div>
475 <div className="tr-p-5 tr-flex tr-flex-col tr-gap-4">
476 {SOCIAL_PLATFORMS.map((platform) => (
477 <SocialRow
478 key={platform.key}
479 Icon={platform.Icon}
480 label={platform.label}
481 color={platform.color}
482 tint={platform.tint}
483 value={settings[platform.key]}
484 settingKey={platform.key}
485 onSettingChange={onSettingChange}
486 disabled={disabled}
487 placeholder={platform.placeholder}
488 />
489 ))}
490 </div>
491 </Card>
492
493 {/* ── Contact Point ─────────────────────────────────────────── */}
494 <Card padding={false}>
495 <div className="tr-px-5 tr-py-4 tr-border-b tr-border-secondary">
496 <h3 className="tr-text-sm tr-font-semibold tr-text-primary tr-m-0">
497 {__('Contact Point', 'thinkrank')}
498 </h3>
499 <p className="tr-text-xs tr-text-tertiary tr-m-0 tr-mt-0.5">
500 {__(
501 'ContactPoint schema nested inside Organization',
502 'thinkrank'
503 )}
504 </p>
505 </div>
506 <div className="tr-p-5 tr-flex tr-flex-col tr-gap-4">
507 <Select
508 label={__('Contact Type', 'thinkrank')}
509 value={settings.organization_contact_type || ''}
510 options={getContactTypeOptions()}
511 onChange={(e) =>
512 onSettingChange(
513 'organization_contact_type',
514 e.target.value
515 )
516 }
517 description={__(
518 'Nature of this contact point',
519 'thinkrank'
520 )}
521 disabled={disabled}
522 />
523 <Input
524 label={__('Contact Phone', 'thinkrank')}
525 prefix={<IconPhone />}
526 type="tel"
527 value={settings.organization_contact_phone || ''}
528 onChange={(e) =>
529 onSettingChange(
530 'organization_contact_phone',
531 e.target.value
532 )
533 }
534 description={__(
535 'Contact phone number for this point',
536 'thinkrank'
537 )}
538 error={phoneError(settings.organization_contact_phone)}
539 disabled={disabled}
540 />
541 <Input
542 label={__('Contact Email', 'thinkrank')}
543 prefix={<IconMail />}
544 value={settings.organization_contact_email || ''}
545 onChange={(e) =>
546 onSettingChange(
547 'organization_contact_email',
548 e.target.value
549 )
550 }
551 description={__('Contact email address', 'thinkrank')}
552 error={emailError(settings.organization_contact_email)}
553 type="email"
554 disabled={disabled}
555 />
556
557 {/* Contact hours — read-only, synced from Local SEO */}
558 <div className="tr-border tr-border-secondary tr-rounded-lg tr-overflow-hidden">
559 <div className="tr-flex tr-items-center tr-gap-2 tr-px-4 tr-py-2.5 tr-bg-white tr-border-b tr-border-secondary">
560 <span className="tr-text-tertiary">
561 <IconClock />
562 </span>
563 <span className="tr-text-xs tr-font-semibold tr-text-secondary">
564 {__('Contact Hours', 'thinkrank')}
565 </span>
566 <span className="tr-ml-auto tr-text-xs tr-text-tertiary tr-italic">
567 {__('Auto-synced from Local SEO', 'thinkrank')}
568 </span>
569 </div>
570 <div className="tr-px-4 tr-py-3">
571 <p className="tr-text-sm tr-text-secondary tr-m-0">
572 {settings.organization_contact_hours ? (
573 settings.organization_contact_hours
574 ) : (
575 <span className="tr-text-tertiary tr-italic">
576 {__(
577 'No hours configured yet set business hours in Site Identity › Business Info.',
578 'thinkrank'
579 )}
580 </span>
581 )}
582 </p>
583 </div>
584 </div>
585 </div>
586 </Card>
587 </div>
588 );
589 };
590
591 export default SchemaOrganizationForm;
592