PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Services / SEOService.php

SEOService.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Services/SEOService.php

1,066 lines 40.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Services\SettingsService;
8
9 /**
10 * SEO Service
11 *
12 * Production-ready centralized SEO management for all Yatra pages
13 * Handles meta tags, Open Graph, Twitter Cards, and Schema markup
14 * Follows SOLID principles for extensibility and maintainability
15 *
16 * @package Yatra\Services
17 */
18 class SEOService
19 {
20 /**
21 * Page types supported by SEO service
22 */
23 public const PAGE_TYPE_TRIP = 'trip';
24 public const PAGE_TYPE_TRIP_ARCHIVE = 'trip_archive';
25 public const PAGE_TYPE_DESTINATION = 'destination';
26 public const PAGE_TYPE_ACTIVITY = 'activity';
27 public const PAGE_TYPE_CATEGORY = 'category';
28 /** Browse-all index: /destination/, /activity/, /trip-category/ (and plain ?yatra_page=). */
29 public const PAGE_TYPE_DESTINATION_LISTING = 'destination_listing';
30 public const PAGE_TYPE_ACTIVITY_LISTING = 'activity_listing';
31 public const PAGE_TYPE_CATEGORY_LISTING = 'category_listing';
32
33 /**
34 * Valid page types for validation
35 */
36 private const VALID_PAGE_TYPES = [
37 self::PAGE_TYPE_TRIP,
38 self::PAGE_TYPE_TRIP_ARCHIVE,
39 self::PAGE_TYPE_DESTINATION,
40 self::PAGE_TYPE_ACTIVITY,
41 self::PAGE_TYPE_CATEGORY,
42 self::PAGE_TYPE_DESTINATION_LISTING,
43 self::PAGE_TYPE_ACTIVITY_LISTING,
44 self::PAGE_TYPE_CATEGORY_LISTING,
45 ];
46
47 /**
48 * Cache key prefix
49 */
50 private const CACHE_KEY_PREFIX = 'yatra_seo_';
51
52 /**
53 * Cache expiration time (1 hour)
54 */
55 private const CACHE_EXPIRATION = 3600;
56
57 /**
58 * SEO data structure
59 */
60 private array $seoData = [
61 'title' => '',
62 'description' => '',
63 'keywords' => '',
64 'image' => '',
65 'url' => '',
66 'type' => '',
67 'published_time' => '',
68 'modified_time' => '',
69 'author' => '',
70 'publisher' => '',
71 ];
72
73 /**
74 * Current page context
75 */
76 private string $pageType = '';
77 private $pageObject = null;
78
79 /**
80 * Instance cache for performance
81 */
82 private static array $instanceCache = [];
83
84 /**
85 * Get SEO instance for specific page type with caching and validation
86 *
87 * @param string $pageType The page type
88 * @param mixed $pageObject The page object (trip, destination, etc.)
89 * @return self SEO service instance
90 * @throws \InvalidArgumentException If page type is invalid
91 */
92 public static function forPage(string $pageType, $pageObject = null): self
93 {
94 // Validate page type
95 if (!in_array($pageType, self::VALID_PAGE_TYPES, true)) {
96 throw new \InvalidArgumentException(
97 sprintf('Invalid page type "%s". Valid types: %s',
98 $pageType,
99 implode(', ', self::VALID_PAGE_TYPES)
100 )
101 );
102 }
103
104 // Generate cache key
105 $cacheKey = self::CACHE_KEY_PREFIX . md5($pageType . serialize($pageObject));
106
107 // Check cache first
108 if (isset(self::$instanceCache[$cacheKey])) {
109 return self::$instanceCache[$cacheKey];
110 }
111
112 // Create new instance
113 $instance = new self();
114 $instance->pageType = $pageType;
115 $instance->pageObject = $pageObject;
116
117 // Cache the instance
118 self::$instanceCache[$cacheKey] = $instance;
119
120 return $instance;
121 }
122
123 /**
124 * Generate and output all SEO meta tags with error handling
125 */
126 public function generateMetaTags(): void
127 {
128 try {
129 $this->collectSEOData();
130
131 if (empty($this->seoData['url'])) {
132 $this->seoData['url'] = $this->getCurrentUrl();
133 }
134
135 $this->outputBasicMetaTags();
136 $this->outputOpenGraphTags();
137 $this->outputTwitterCardTags();
138 $this->outputAdvancedMetaTags();
139 $this->outputPaginationRelLinks();
140 $this->outputSchemaMarkup();
141
142 } catch (\Exception $e) {
143 // Log error and fail gracefully
144
145 // Output basic fallback meta tags
146 $this->outputFallbackMetaTags();
147 }
148 }
149
150 /**
151 * Get page title (for document_title filter) with error handling
152 *
153 * @return string The formatted page title
154 */
155 public function getTitle(): string
156 {
157 try {
158 $this->collectSEOData();
159 $title = $this->sanitizeText($this->seoData['title'] ?? '');
160
161 if (!empty($title)) {
162 return $title . ' - ' . $this->sanitizeText(get_bloginfo('name'));
163 }
164
165 return '';
166 } catch (\Exception $e) {
167 return '';
168 }
169 }
170
171 /**
172 * Canonical URL for the current HTTP request (og:url, link rel=canonical).
173 * Must not use the HTTP referer.
174 */
175 private function getCurrentUrl(): string
176 {
177 if (!empty($_SERVER['HTTP_HOST'])) {
178 $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
179 || (!empty($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443')
180 || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
181 && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');
182 $scheme = $https ? 'https' : 'http';
183 $host = strtolower(\sanitize_text_field(\wp_unslash((string) $_SERVER['HTTP_HOST'])));
184 $uri = $_SERVER['REQUEST_URI'] ?? '/';
185 $uri = \is_string($uri) && $uri !== '' ? $uri : '/';
186 $url = $scheme . '://' . $host . $uri;
187 $validated = \filter_var($url, \FILTER_VALIDATE_URL);
188 if ($validated) {
189 return \esc_url_raw($validated);
190 }
191 }
192
193 global $wp;
194 if (isset($wp->request) && \is_string($wp->request)) {
195 $base = \home_url(\user_trailingslashit($wp->request));
196 if (!empty($_GET) && \is_array($_GET)) {
197 $base = \add_query_arg(\array_map('sanitize_text_field', \wp_unslash($_GET)), $base);
198 }
199
200 return \esc_url_raw($base);
201 }
202
203 return \home_url('/');
204 }
205
206 /**
207 * Output fallback meta tags in case of errors
208 */
209 private function outputFallbackMetaTags(): void
210 {
211 $title = $this->sanitizeText(get_bloginfo('name'));
212 $description = $this->sanitizeText(get_bloginfo('description'));
213
214 if (!empty($description)) {
215 echo '<meta name="description" content="' . esc_attr($description) . '">' . "\n";
216 }
217
218 echo '<meta property="og:title" content="' . esc_attr($title) . '">' . "\n";
219 echo '<meta property="og:type" content="website">' . "\n";
220 echo '<meta property="og:url" content="' . esc_url(home_url()) . '">' . "\n";
221 echo '<meta name="twitter:card" content="summary">' . "\n";
222 echo '<link rel="canonical" href="' . esc_url(home_url()) . '">' . "\n";
223 }
224
225 /**
226 * Sanitize text for SEO output
227 *
228 * @param string $text Text to sanitize
229 * @return string Sanitized text
230 */
231 private function sanitizeText(string $text): string
232 {
233 return wp_strip_all_tags(strip_tags($text));
234 }
235
236 /**
237 * Expand SEO meta placeholders for taxonomy terms.
238 *
239 * The destination / activity / category meta forms tell the operator to
240 * "Use {name} as placeholder" in the meta title, description and keywords.
241 * Those values are stored verbatim (the term may be renamed later), so the
242 * token must be substituted here, at render time, with the term's name.
243 * No-op when the value contains no token, so plain values are untouched.
244 *
245 * @param string $value Stored meta value, possibly containing {name}
246 * @param string $name Term name to substitute
247 * @return string Value with {name} replaced
248 */
249 private function expandTermTokens(string $value, string $name): string
250 {
251 if ($value === '' || strpos($value, '{') === false) {
252 return $value;
253 }
254
255 return str_replace(['{name}', '{Name}'], $name, $value);
256 }
257
258 /**
259 * Validate and sanitize URL
260 *
261 * @param string $url URL to validate
262 * @return string Validated URL
263 */
264 private function validateUrl(string $url): string
265 {
266 return esc_url(filter_var($url, FILTER_VALIDATE_URL) ?: '');
267 }
268
269 /**
270 * Collect SEO data based on page type with error handling
271 */
272 private function collectSEOData(): void
273 {
274 try {
275 switch ($this->pageType) {
276 case self::PAGE_TYPE_TRIP_ARCHIVE:
277 $this->collectTripArchiveData();
278 break;
279 case self::PAGE_TYPE_DESTINATION:
280 $this->collectDestinationData();
281 break;
282 case self::PAGE_TYPE_ACTIVITY:
283 $this->collectActivityData();
284 break;
285 case self::PAGE_TYPE_CATEGORY:
286 $this->collectCategoryData();
287 break;
288 case self::PAGE_TYPE_DESTINATION_LISTING:
289 $this->collectDestinationListingData();
290 break;
291 case self::PAGE_TYPE_ACTIVITY_LISTING:
292 $this->collectActivityListingData();
293 break;
294 case self::PAGE_TYPE_CATEGORY_LISTING:
295 $this->collectCategoryListingData();
296 break;
297 case self::PAGE_TYPE_TRIP:
298 $this->collectTripData();
299 break;
300 default:
301 throw new \InvalidArgumentException("Unsupported page type: {$this->pageType}");
302 }
303 } catch (\Exception $e) {
304
305 // Set default values on error
306 $this->setDefaultSEOData();
307 }
308 }
309
310 /**
311 * Set default SEO data as fallback
312 */
313 private function setDefaultSEOData(): void
314 {
315 $this->seoData = array_merge($this->seoData, [
316 'title' => get_bloginfo('name'),
317 'description' => get_bloginfo('description'),
318 'author' => get_bloginfo('name'),
319 'publisher' => get_bloginfo('name'),
320 'type' => 'website',
321 'url' => $this->getCurrentUrl(),
322 'published_time' => date('c'),
323 'modified_time' => date('c'),
324 ]);
325 }
326
327 /**
328 * Collect SEO data for trip archive page with validation
329 */
330 private function collectTripArchiveData(): void
331 {
332 try {
333 $this->seoData['title'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_title', ''));
334 $this->seoData['description'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
335 $this->seoData['keywords'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
336 if ($this->seoData['title'] === '') {
337 $this->seoData['title'] = $this->sanitizeText(\__('All Trips', 'yatra'));
338 }
339 if ($this->seoData['description'] === '') {
340 $tagline = $this->sanitizeText(\get_bloginfo('description'));
341 $this->seoData['description'] = $tagline !== ''
342 ? $tagline
343 : $this->sanitizeText(
344 \__('Browse and compare trips, then open a trip for full details and booking.', 'yatra')
345 );
346 }
347
348 // Get image from settings (attachment ID) with validation
349 $imageId = SettingsService::getInt('seo_trip_meta_image', 0);
350
351 if ($imageId > 0) {
352 $imageUrl = wp_get_attachment_url($imageId);
353
354 if ($imageUrl && filter_var($imageUrl, FILTER_VALIDATE_URL)) {
355 $this->seoData['image'] = $this->validateUrl($imageUrl);
356 }
357 }
358
359 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
360 $this->seoData['type'] = 'website';
361 $this->seoData['published_time'] = date('c');
362 $this->seoData['modified_time'] = date('c');
363 $this->seoData['author'] = $this->sanitizeText(get_bloginfo('name'));
364 $this->seoData['publisher'] = $this->sanitizeText(get_bloginfo('name'));
365
366 } catch (\Exception $e) {
367 $this->setDefaultSEOData();
368 }
369 }
370
371 private function collectDestinationListingData(): void
372 {
373 $this->seoData['title'] = $this->sanitizeText(\__('All Destinations', 'yatra'));
374 $this->seoData['description'] = $this->sanitizeText(
375 \__('Explore destinations we visit and find trips that match where you want to go.', 'yatra')
376 );
377 $this->seoData['keywords'] = '';
378 $this->seoData['image'] = '';
379 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
380 $this->seoData['type'] = 'website';
381 $this->seoData['published_time'] = \date('c');
382 $this->seoData['modified_time'] = \date('c');
383 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
384 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
385 }
386
387 private function collectActivityListingData(): void
388 {
389 $this->seoData['title'] = $this->sanitizeText(\__('All Activities', 'yatra'));
390 $this->seoData['description'] = $this->sanitizeText(
391 \__('Browse experiences and find trips built around the activities you love.', 'yatra')
392 );
393 $this->seoData['keywords'] = '';
394 $this->seoData['image'] = '';
395 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
396 $this->seoData['type'] = 'website';
397 $this->seoData['published_time'] = \date('c');
398 $this->seoData['modified_time'] = \date('c');
399 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
400 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
401 }
402
403 private function collectCategoryListingData(): void
404 {
405 $this->seoData['title'] = $this->sanitizeText(\__('Trip Categories', 'yatra'));
406 $this->seoData['description'] = $this->sanitizeText(
407 \__('Browse trips by style or theme—adventure, family, luxury, and more.', 'yatra')
408 );
409 $this->seoData['keywords'] = '';
410 $this->seoData['image'] = '';
411 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
412 $this->seoData['type'] = 'website';
413 $this->seoData['published_time'] = \date('c');
414 $this->seoData['modified_time'] = \date('c');
415 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
416 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
417 }
418
419 /**
420 * Collect SEO data for destination page
421 */
422 private function collectDestinationData(): void
423 {
424 if (!$this->pageObject) {
425 return;
426 }
427
428 $destination = $this->pageObject;
429 $metadata = [];
430 if (!empty($destination->metadata)) {
431 if (\is_array($destination->metadata)) {
432 $metadata = $destination->metadata;
433 } else {
434 $maybe = \maybe_unserialize($destination->metadata);
435 $metadata = \is_array($maybe) ? $maybe : [];
436 }
437 }
438
439 $name = (string) ($destination->name ?? '');
440 $title = $metadata['seo_title'] ?? $destination->name ?? '';
441 $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) $title, $name));
442 $descRaw = $metadata['seo_description'] ?? $destination->description ?? '';
443 $descRaw = $this->expandTermTokens((string) $descRaw, $name);
444 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, ''));
445 $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
446
447 // Get featured image or gallery image
448 $this->seoData['image'] = $destination->featured_image_url ??
449 ($destination->gallery_images[0] ?? '');
450
451 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
452
453 $this->seoData['type'] = 'place';
454 $this->seoData['published_time'] = date('c', strtotime($destination->created_at ?? 'now'));
455 $this->seoData['modified_time'] = date('c', strtotime($destination->updated_at ?? 'now'));
456 $this->seoData['author'] = get_bloginfo('name');
457 $this->seoData['publisher'] = get_bloginfo('name');
458 }
459
460 /**
461 * Collect SEO data for activity page
462 */
463 private function collectActivityData(): void
464 {
465 if (!$this->pageObject) {
466 return;
467 }
468
469 $activity = $this->pageObject;
470 $metadata = [];
471 if (!empty($activity->metadata)) {
472 if (\is_array($activity->metadata)) {
473 $metadata = $activity->metadata;
474 } else {
475 $maybe = \maybe_unserialize($activity->metadata);
476 $metadata = \is_array($maybe) ? $maybe : [];
477 }
478 }
479
480 $name = (string) ($activity->name ?? '');
481 $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $activity->name ?? ''), $name));
482 $descRaw = $metadata['seo_description'] ?? $activity->description ?? '';
483 $descRaw = $this->expandTermTokens((string) $descRaw, $name);
484 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, ''));
485 $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
486
487 // Get featured image or gallery image
488 $this->seoData['image'] = $activity->featured_image_url ??
489 ($activity->gallery_images[0] ?? '');
490
491 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
492
493 $this->seoData['type'] = 'article';
494 $this->seoData['published_time'] = date('c', strtotime($activity->created_at ?? 'now'));
495 $this->seoData['modified_time'] = date('c', strtotime($activity->updated_at ?? 'now'));
496 $this->seoData['author'] = get_bloginfo('name');
497 $this->seoData['publisher'] = get_bloginfo('name');
498 }
499
500 /**
501 * Collect SEO data for category page
502 */
503 private function collectCategoryData(): void
504 {
505 if (!$this->pageObject) {
506 return;
507 }
508
509 $category = $this->pageObject;
510 $metadata = [];
511 if (!empty($category->metadata)) {
512 if (\is_array($category->metadata)) {
513 $metadata = $category->metadata;
514 } else {
515 $maybe = \maybe_unserialize($category->metadata);
516 $metadata = \is_array($maybe) ? $maybe : [];
517 }
518 }
519
520 $name = (string) ($category->name ?? '');
521 $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $category->name ?? ''), $name));
522 $descRaw = $metadata['seo_description'] ?? $category->description ?? '';
523 $descRaw = $this->expandTermTokens((string) $descRaw, $name);
524 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, ''));
525 $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
526
527 // Get featured image or gallery image
528 $this->seoData['image'] = $category->featured_image_url ??
529 ($category->gallery_images[0] ?? '');
530
531 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
532
533 $this->seoData['type'] = 'article';
534 $this->seoData['published_time'] = date('c', strtotime($category->created_at ?? 'now'));
535 $this->seoData['modified_time'] = date('c', strtotime($category->updated_at ?? 'now'));
536 $this->seoData['author'] = get_bloginfo('name');
537 $this->seoData['publisher'] = get_bloginfo('name');
538 }
539
540 /**
541 * Collect SEO data for single trip page
542 */
543 private function collectTripData(): void
544 {
545 if (!$this->pageObject) {
546 return;
547 }
548
549 $trip = $this->pageObject;
550
551 $customTitle = trim((string) ($trip->meta_title ?? ''));
552 $displayTitle = '';
553 if (\is_object($trip) && \method_exists($trip, 'getTitle')) {
554 $displayTitle = $this->sanitizeText($trip->getTitle());
555 } else {
556 $displayTitle = $this->sanitizeText((string) ($trip->title ?? $trip->name ?? ''));
557 }
558
559 $this->seoData['title'] = $customTitle !== ''
560 ? $this->sanitizeText($customTitle)
561 : $displayTitle;
562
563 $customDesc = trim((string) ($trip->meta_description ?? ''));
564 $fallbackDesc = '';
565 if (\is_object($trip) && \method_exists($trip, 'getShortDescription')) {
566 $short = $trip->getShortDescription();
567 if (!empty($short)) {
568 $fallbackDesc = $this->sanitizeText(\wp_strip_all_tags((string) $short));
569 }
570 }
571 if ($fallbackDesc === '' && !empty($trip->description)) {
572 $fallbackDesc = $this->sanitizeText(
573 \wp_trim_words(\wp_strip_all_tags((string) $trip->description), 40, '')
574 );
575 }
576 if ($fallbackDesc === '') {
577 $fallbackDesc = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
578 }
579 $this->seoData['description'] = $customDesc !== ''
580 ? $this->sanitizeText($customDesc)
581 : $fallbackDesc;
582
583 $customKeywords = trim((string) ($trip->meta_keywords ?? ''));
584 $this->seoData['keywords'] = $customKeywords !== ''
585 ? $this->sanitizeText($customKeywords)
586 : $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
587
588 $this->seoData['image'] = $this->resolveTripSeoImageUrl($trip);
589
590 $this->seoData['url'] = function_exists('yatra_get_trip_permalink')
591 ? yatra_get_trip_permalink($trip)
592 : home_url('/' . SettingsService::getTripBase() . '/' . ($trip->slug ?? ''));
593
594 $this->seoData['type'] = 'article';
595 $this->seoData['published_time'] = date('c', strtotime($trip->created_at ?? 'now'));
596 $this->seoData['modified_time'] = date('c', strtotime($trip->updated_at ?? 'now'));
597 $this->seoData['author'] = get_bloginfo('name');
598 $this->seoData['publisher'] = get_bloginfo('name');
599 }
600
601 /**
602 * Resolve share/SEO image URL for a trip (featured, gallery, then global trip SEO image setting).
603 *
604 * @param object $trip
605 */
606 private function resolveTripSeoImageUrl(object $trip): string
607 {
608 $raw = '';
609 if (!empty($trip->featured_image_url)) {
610 $raw = (string) $trip->featured_image_url;
611 } elseif (!empty($trip->gallery_images) && \is_array($trip->gallery_images)) {
612 $first = $trip->gallery_images[0] ?? null;
613 if (\is_string($first)) {
614 $raw = $first;
615 } elseif (\is_array($first)) {
616 $raw = (string) ($first['url'] ?? $first['src'] ?? '');
617 }
618 }
619
620 if ($raw === '') {
621 $imageId = SettingsService::getInt('seo_trip_meta_image', 0);
622 if ($imageId > 0) {
623 $url = \wp_get_attachment_url($imageId);
624 if ($url && \filter_var($url, FILTER_VALIDATE_URL)) {
625 $raw = $url;
626 }
627 }
628 }
629
630 return $raw !== '' ? $this->validateUrl($raw) : '';
631 }
632
633 /**
634 * The page's language as WordPress reports it, reduced to what hreflang
635 * and Open Graph accept: language + optional region (`de-DE`, `en-US`,
636 * `ca`). Read at render time through get_locale()/the `locale` filter, so
637 * a German install, WPML and Polylang per-page languages all resolve
638 * correctly. WordPress variant locales such as `de_DE_formal` ("Deutsch
639 * (Sie)") or `pt_PT_ao90` carry a third segment that is not a region —
640 * Google ignores an hreflang like `de-DE-formal` and Facebook rejects
641 * `de_DE_formal` — so only the first two segments are kept. Falls back to
642 * `en-US` when WP has no locale, which keeps existing English sites
643 * byte-identical.
644 */
645 private function languageTag(): string
646 {
647 $locale = (string) get_locale();
648 if ($locale === '') {
649 $locale = str_replace('-', '_', (string) get_bloginfo('language'));
650 }
651
652 $parts = preg_split('/[_-]/', $locale) ?: [];
653 $language = strtolower((string) ($parts[0] ?? ''));
654 if (!preg_match('/^[a-z]{2,3}$/', $language)) {
655 return 'en-US';
656 }
657
658 $region = strtoupper((string) ($parts[1] ?? ''));
659
660 return preg_match('/^[A-Z]{2}$/', $region) ? $language . '-' . $region : $language;
661 }
662
663 /**
664 * Same language in Open Graph form (`de_DE`, `en_US`).
665 */
666 private function ogLocale(): string
667 {
668 return str_replace('-', '_', $this->languageTag());
669 }
670
671 /**
672 * Output basic meta tags
673 */
674 private function outputBasicMetaTags(): void
675 {
676 if (!empty($this->seoData['description'])) {
677 echo '<meta name="description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
678 }
679
680 if (!empty($this->seoData['keywords'])) {
681 echo '<meta name="keywords" content="' . esc_attr($this->seoData['keywords']) . '">' . "\n";
682 }
683 }
684
685 /**
686 * Output Open Graph meta tags
687 */
688 private function outputOpenGraphTags(): void
689 {
690 echo '<meta property="og:locale" content="' . esc_attr($this->ogLocale()) . '">' . "\n";
691 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
692 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
693 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
694 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
695 echo '<meta property="og:url" content="' . esc_url($this->seoData['url']) . '">' . "\n";
696
697 if (!empty($this->seoData['image'])) {
698 echo '<meta property="og:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
699 echo '<meta property="og:image:width" content="1200">' . "\n";
700 echo '<meta property="og:image:height" content="630">' . "\n";
701 echo '<meta property="og:image:alt" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
702 }
703
704 if (!empty($this->seoData['published_time'])) {
705 echo '<meta property="article:published_time" content="' . esc_attr($this->seoData['published_time']) . '">' . "\n";
706 }
707
708 if (!empty($this->seoData['modified_time'])) {
709 echo '<meta property="article:modified_time" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
710 }
711 }
712
713 /**
714 * Output Twitter Card meta tags
715 */
716 private function outputTwitterCardTags(): void
717 {
718 echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
719 echo '<meta name="twitter:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
720 echo '<meta name="twitter:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
721
722 if (!empty($this->seoData['image'])) {
723 echo '<meta name="twitter:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
724 }
725 }
726
727 /**
728 * Output advanced meta tags
729 */
730 private function outputAdvancedMetaTags(): void
731 {
732 echo '<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
733 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
734 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
735 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
736 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
737 echo '<link rel="alternate" hreflang="' . esc_attr($this->languageTag()) . '" href="' . esc_url($this->seoData['url']) . '">' . "\n";
738 echo '<meta name="revisit-after" content="7 days">' . "\n";
739 echo '<meta name="distribution" content="global">' . "\n";
740 echo '<meta name="rating" content="general">' . "\n";
741 }
742
743 /**
744 * rel="prev" / rel="next" for paginated trip archive and taxonomy listings (Google-supported series hints).
745 */
746 private function outputPaginationRelLinks(): void
747 {
748 $prevUrl = '';
749 $nextUrl = '';
750
751 switch ($this->pageType) {
752 case self::PAGE_TYPE_TRIP_ARCHIVE:
753 $ctx = $GLOBALS['yatra_trip_listing_context'] ?? null;
754 if (!\is_array($ctx)) {
755 return;
756 }
757 $pagination = $ctx['pagination'] ?? null;
758 if (!\is_array($pagination)) {
759 return;
760 }
761 if (!empty($pagination['prev_url']) && \is_string($pagination['prev_url'])) {
762 $prevUrl = $pagination['prev_url'];
763 }
764 if (!empty($pagination['next_url']) && \is_string($pagination['next_url'])) {
765 $nextUrl = $pagination['next_url'];
766 }
767 break;
768
769 case self::PAGE_TYPE_DESTINATION:
770 case self::PAGE_TYPE_ACTIVITY:
771 case self::PAGE_TYPE_CATEGORY:
772 $td = $GLOBALS['yatra_taxonomy_data'] ?? null;
773 if (!$td || !\is_object($td)) {
774 return;
775 }
776 $total = \max(1, (int) ($td->trips_pages ?? 1));
777 $current = (int) ($td->trips_current_page ?? 1);
778 if ($current < 1) {
779 $current = 1;
780 }
781 $current = \min($current, $total);
782 if (!\function_exists('yatra_build_current_request_paged_url')) {
783 return;
784 }
785 if ($current > 1) {
786 $prevUrl = (string) yatra_build_current_request_paged_url($current - 1);
787 }
788 if ($current < $total) {
789 $nextUrl = (string) yatra_build_current_request_paged_url($current + 1);
790 }
791 break;
792
793 case self::PAGE_TYPE_DESTINATION_LISTING:
794 case self::PAGE_TYPE_ACTIVITY_LISTING:
795 case self::PAGE_TYPE_CATEGORY_LISTING:
796 $meta = $GLOBALS['yatra_archive_browse_pagination'] ?? null;
797 if (!\is_array($meta)) {
798 return;
799 }
800 $total = \max(1, (int) ($meta['total_pages'] ?? 1));
801 $current = (int) ($meta['current_page'] ?? 1);
802 if ($current < 1) {
803 $current = 1;
804 }
805 $current = \min($current, $total);
806 if (!\function_exists('yatra_build_archive_listing_url')) {
807 return;
808 }
809 if ($current > 1) {
810 $prevUrl = (string) yatra_build_archive_listing_url($current - 1);
811 }
812 if ($current < $total) {
813 $nextUrl = (string) yatra_build_archive_listing_url($current + 1);
814 }
815 break;
816
817 default:
818 return;
819 }
820
821 if ($prevUrl !== '') {
822 echo '<link rel="prev" href="' . esc_url($prevUrl) . '">' . "\n";
823 }
824 if ($nextUrl !== '') {
825 echo '<link rel="next" href="' . esc_url($nextUrl) . '">' . "\n";
826 }
827 }
828
829 /**
830 * Output Schema markup
831 */
832 private function outputSchemaMarkup(): void
833 {
834 $schema = $this->generateSchemaMarkup();
835 if (!empty($schema)) {
836 // JSON_HEX_TAG|JSON_HEX_AMP escape < > & as \u00xx so no string value
837 // (e.g. user-submitted review text/author) can break out of this
838 // <script> block — a </script> in a review would otherwise be XSS.
839 // Google parses the \u-escaped JSON identically.
840 echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
841 }
842 }
843
844 /**
845 * Generate schema markup based on page type
846 */
847 private function generateSchemaMarkup(): array
848 {
849 $baseSchema = [
850 '@context' => 'https://schema.org',
851 'name' => $this->seoData['title'],
852 'description' => $this->seoData['description'],
853 'url' => $this->seoData['url'],
854 'publisher' => [
855 '@type' => 'Organization',
856 'name' => get_bloginfo('name'),
857 'url' => home_url()
858 ],
859 'dateModified' => $this->seoData['modified_time'],
860 'datePublished' => $this->seoData['published_time'],
861 'inLanguage' => $this->languageTag(),
862 'isPartOf' => [
863 '@type' => 'WebSite',
864 'name' => get_bloginfo('name'),
865 'url' => home_url()
866 ]
867 ];
868
869 // Add image if available
870 if (!empty($this->seoData['image'])) {
871 $baseSchema['image'] = $this->seoData['image'];
872 }
873
874 switch ($this->pageType) {
875 case self::PAGE_TYPE_TRIP_ARCHIVE:
876 case self::PAGE_TYPE_DESTINATION_LISTING:
877 case self::PAGE_TYPE_ACTIVITY_LISTING:
878 case self::PAGE_TYPE_CATEGORY_LISTING:
879 return $this->generateCollectionPageSchema($baseSchema);
880 case self::PAGE_TYPE_DESTINATION:
881 return $this->generatePlaceSchema($baseSchema);
882 case self::PAGE_TYPE_TRIP:
883 // A tour with ratings/reviews must be a Google-supported review
884 // type (Product) with the rating + reviews nested inside it —
885 // NOT a TouristTrip/Article, which Google rejects for review
886 // snippets ("invalid object type for parent_node").
887 return $this->generateTripProductSchema($baseSchema);
888 case self::PAGE_TYPE_ACTIVITY:
889 case self::PAGE_TYPE_CATEGORY:
890 return $this->generateArticleSchema($baseSchema);
891 default:
892 return $baseSchema;
893 }
894 }
895
896 /**
897 * Generate CollectionPage schema for archive pages
898 */
899 private function generateCollectionPageSchema(array $baseSchema): array
900 {
901 return array_merge($baseSchema, [
902 '@type' => 'CollectionPage',
903 'mainEntity' => [
904 '@type' => 'ItemList',
905 'numberOfItems' => 0, // This should be dynamically populated
906 'itemListElement' => []
907 ]
908 ]);
909 }
910
911 /**
912 * Generate Place schema for destination pages
913 */
914 private function generatePlaceSchema(array $baseSchema): array
915 {
916 return array_merge($baseSchema, [
917 '@type' => 'Place',
918 'address' => $this->pageObject->address ?? '',
919 'geo' => [
920 '@type' => 'GeoCoordinates',
921 'latitude' => $this->pageObject->latitude ?? '',
922 'longitude' => $this->pageObject->longitude ?? ''
923 ]
924 ]);
925 }
926
927 /**
928 * Generate Article schema for content pages
929 */
930 private function generateArticleSchema(array $baseSchema): array
931 {
932 return array_merge($baseSchema, [
933 '@type' => 'Article',
934 'headline' => $this->seoData['title'],
935 'author' => [
936 '@type' => 'Organization',
937 'name' => get_bloginfo('name')
938 ]
939 ]);
940 }
941
942 /**
943 * Generate Product schema for a single trip, with rating + reviews nested
944 * INSIDE the product (the structure Google requires for review snippets).
945 *
946 * `aggregateRating` and `review` are added only when approved reviews exist —
947 * an empty aggregateRating is itself a structured-data error. Each review
948 * carries the required author / reviewRating / reviewBody / datePublished so
949 * Google no longer reports "Missing field author" or "Missing field itemReviewed".
950 */
951 private function generateTripProductSchema(array $baseSchema): array
952 {
953 $trip = $this->pageObject;
954
955 $tripId = (\is_object($trip) && isset($trip->id)) ? (int) $trip->id : 0;
956 $avg = (\is_object($trip) && \method_exists($trip, 'getAverageRating'))
957 ? (float) $trip->getAverageRating()
958 : 0.0;
959 $count = (\is_object($trip) && \method_exists($trip, 'getReviewCount'))
960 ? (int) $trip->getReviewCount()
961 : 0;
962
963 // No approved reviews → keep the generic Article (unchanged behaviour).
964 // A Product is emitted ONLY when there's a real rating to nest, which is
965 // exactly what fixes the review markup — and it avoids emitting a bare
966 // Product (offers/review/rating all absent) that Google would flag as
967 // incomplete on the many tours that have no reviews yet.
968 if (!($tripId > 0 && $count > 0 && $avg > 0)) {
969 return $this->generateArticleSchema($baseSchema);
970 }
971
972 $schema = [
973 '@context' => 'https://schema.org',
974 '@type' => 'Product',
975 'name' => $this->seoData['title'],
976 'url' => $this->seoData['url'],
977 'brand' => [
978 '@type' => 'Brand',
979 'name' => get_bloginfo('name'),
980 ],
981 ];
982 if (!empty($this->seoData['description'])) {
983 $schema['description'] = $this->seoData['description'];
984 }
985 if (!empty($this->seoData['image'])) {
986 $schema['image'] = $this->seoData['image'];
987 }
988
989 $schema['aggregateRating'] = [
990 '@type' => 'AggregateRating',
991 'ratingValue' => (string) round($avg, 1),
992 'reviewCount' => (string) $count,
993 'bestRating' => '5',
994 'worstRating' => '1',
995 ];
996
997 $rows = [];
998 try {
999 $rows = (new \Yatra\Repositories\ReviewRepository())->findApprovedByTripId($tripId, 10);
1000 } catch (\Throwable $e) {
1001 $rows = [];
1002 }
1003
1004 $reviews = [];
1005 foreach ($rows as $row) {
1006 $rating = (int) ($row->rating ?? 0);
1007 if ($rating < 1 || $rating > 5) {
1008 continue;
1009 }
1010 // Strip tags on every user-derived value — even though the emitter
1011 // now \u-escapes < > &, keep the data itself clean/plain-text.
1012 $authorName = trim(\wp_strip_all_tags((string) ($row->author_name ?? $row->user_display_name ?? '')));
1013 if ($authorName === '') {
1014 $authorName = __('Anonymous', 'yatra');
1015 }
1016
1017 $review = [
1018 '@type' => 'Review',
1019 'author' => ['@type' => 'Person', 'name' => $authorName],
1020 'reviewRating' => [
1021 '@type' => 'Rating',
1022 'ratingValue' => (string) $rating,
1023 'bestRating' => '5',
1024 'worstRating' => '1',
1025 ],
1026 ];
1027
1028 $title = $this->sanitizeText(trim((string) ($row->title ?? '')));
1029 if ($title !== '') {
1030 $review['name'] = $title;
1031 }
1032 $body = $this->sanitizeText(trim(\wp_strip_all_tags((string) ($row->content ?? ''))));
1033 if ($body !== '') {
1034 $review['reviewBody'] = $body;
1035 }
1036 $created = (string) ($row->created_at ?? '');
1037 if ($created !== '') {
1038 $ts = strtotime($created);
1039 if ($ts) {
1040 $review['datePublished'] = date('Y-m-d', $ts);
1041 }
1042 }
1043
1044 $reviews[] = $review;
1045 }
1046
1047 if (!empty($reviews)) {
1048 $schema['review'] = $reviews;
1049 }
1050
1051 return $schema;
1052 }
1053
1054 /**
1055 * Truncate text to specified length
1056 */
1057 private function truncateText(string $text, int $length): string
1058 {
1059 if (strlen($text) <= $length) {
1060 return $text;
1061 }
1062
1063 return substr($text, 0, $length - 3) . '...';
1064 }
1065 }
1066