PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.6
Yatra – Travel Booking & Tour Operator Software v3.0.2.6
3.0.16 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 All 84 releases
yatra / app / Services / SEOService.php

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

883 lines 32.8 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 error_log('Yatra SEO Service Error: ' . $e->getMessage());
145
146 // Output basic fallback meta tags
147 $this->outputFallbackMetaTags();
148 }
149 }
150
151 /**
152 * Get page title (for document_title filter) with error handling
153 *
154 * @return string The formatted page title
155 */
156 public function getTitle(): string
157 {
158 try {
159 $this->collectSEOData();
160 $title = $this->sanitizeText($this->seoData['title'] ?? '');
161
162 if (!empty($title)) {
163 return $title . ' - ' . $this->sanitizeText(get_bloginfo('name'));
164 }
165
166 return '';
167 } catch (\Exception $e) {
168 error_log('Yatra SEO Service getTitle Error: ' . $e->getMessage());
169 return '';
170 }
171 }
172
173 /**
174 * Canonical URL for the current HTTP request (og:url, link rel=canonical).
175 * Must not use the HTTP referer.
176 */
177 private function getCurrentUrl(): string
178 {
179 if (!empty($_SERVER['HTTP_HOST'])) {
180 $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
181 || (!empty($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443')
182 || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
183 && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');
184 $scheme = $https ? 'https' : 'http';
185 $host = strtolower(\sanitize_text_field(\wp_unslash((string) $_SERVER['HTTP_HOST'])));
186 $uri = $_SERVER['REQUEST_URI'] ?? '/';
187 $uri = \is_string($uri) && $uri !== '' ? $uri : '/';
188 $url = $scheme . '://' . $host . $uri;
189 $validated = \filter_var($url, \FILTER_VALIDATE_URL);
190 if ($validated) {
191 return \esc_url_raw($validated);
192 }
193 }
194
195 global $wp;
196 if (isset($wp->request) && \is_string($wp->request)) {
197 $base = \home_url(\user_trailingslashit($wp->request));
198 if (!empty($_GET) && \is_array($_GET)) {
199 $base = \add_query_arg(\array_map('sanitize_text_field', \wp_unslash($_GET)), $base);
200 }
201
202 return \esc_url_raw($base);
203 }
204
205 return \home_url('/');
206 }
207
208 /**
209 * Output fallback meta tags in case of errors
210 */
211 private function outputFallbackMetaTags(): void
212 {
213 $title = $this->sanitizeText(get_bloginfo('name'));
214 $description = $this->sanitizeText(get_bloginfo('description'));
215
216 if (!empty($description)) {
217 echo '<meta name="description" content="' . esc_attr($description) . '">' . "\n";
218 }
219
220 echo '<meta property="og:title" content="' . esc_attr($title) . '">' . "\n";
221 echo '<meta property="og:type" content="website">' . "\n";
222 echo '<meta property="og:url" content="' . esc_url(home_url()) . '">' . "\n";
223 echo '<meta name="twitter:card" content="summary">' . "\n";
224 echo '<link rel="canonical" href="' . esc_url(home_url()) . '">' . "\n";
225 }
226
227 /**
228 * Sanitize text for SEO output
229 *
230 * @param string $text Text to sanitize
231 * @return string Sanitized text
232 */
233 private function sanitizeText(string $text): string
234 {
235 return wp_strip_all_tags(strip_tags($text));
236 }
237
238 /**
239 * Validate and sanitize URL
240 *
241 * @param string $url URL to validate
242 * @return string Validated URL
243 */
244 private function validateUrl(string $url): string
245 {
246 return esc_url(filter_var($url, FILTER_VALIDATE_URL) ?: '');
247 }
248
249 /**
250 * Collect SEO data based on page type with error handling
251 */
252 private function collectSEOData(): void
253 {
254 try {
255 switch ($this->pageType) {
256 case self::PAGE_TYPE_TRIP_ARCHIVE:
257 $this->collectTripArchiveData();
258 break;
259 case self::PAGE_TYPE_DESTINATION:
260 $this->collectDestinationData();
261 break;
262 case self::PAGE_TYPE_ACTIVITY:
263 $this->collectActivityData();
264 break;
265 case self::PAGE_TYPE_CATEGORY:
266 $this->collectCategoryData();
267 break;
268 case self::PAGE_TYPE_DESTINATION_LISTING:
269 $this->collectDestinationListingData();
270 break;
271 case self::PAGE_TYPE_ACTIVITY_LISTING:
272 $this->collectActivityListingData();
273 break;
274 case self::PAGE_TYPE_CATEGORY_LISTING:
275 $this->collectCategoryListingData();
276 break;
277 case self::PAGE_TYPE_TRIP:
278 $this->collectTripData();
279 break;
280 default:
281 throw new \InvalidArgumentException("Unsupported page type: {$this->pageType}");
282 }
283 } catch (\Exception $e) {
284 error_log('Yatra SEO Service collectSEOData Error: ' . $e->getMessage());
285
286 // Set default values on error
287 $this->setDefaultSEOData();
288 }
289 }
290
291 /**
292 * Set default SEO data as fallback
293 */
294 private function setDefaultSEOData(): void
295 {
296 $this->seoData = array_merge($this->seoData, [
297 'title' => get_bloginfo('name'),
298 'description' => get_bloginfo('description'),
299 'author' => get_bloginfo('name'),
300 'publisher' => get_bloginfo('name'),
301 'type' => 'website',
302 'url' => $this->getCurrentUrl(),
303 'published_time' => date('c'),
304 'modified_time' => date('c'),
305 ]);
306 }
307
308 /**
309 * Collect SEO data for trip archive page with validation
310 */
311 private function collectTripArchiveData(): void
312 {
313 try {
314 $this->seoData['title'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_title', ''));
315 $this->seoData['description'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
316 $this->seoData['keywords'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
317 if ($this->seoData['title'] === '') {
318 $this->seoData['title'] = $this->sanitizeText(\__('All Trips', 'yatra'));
319 }
320 if ($this->seoData['description'] === '') {
321 $tagline = $this->sanitizeText(\get_bloginfo('description'));
322 $this->seoData['description'] = $tagline !== ''
323 ? $tagline
324 : $this->sanitizeText(
325 \__('Browse and compare trips, then open a trip for full details and booking.', 'yatra')
326 );
327 }
328
329 // Get image from settings (attachment ID) with validation
330 $imageId = SettingsService::getInt('seo_trip_meta_image', 0);
331
332 if ($imageId > 0) {
333 $imageUrl = wp_get_attachment_url($imageId);
334
335 if ($imageUrl && filter_var($imageUrl, FILTER_VALIDATE_URL)) {
336 $this->seoData['image'] = $this->validateUrl($imageUrl);
337 }
338 }
339
340 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
341 $this->seoData['type'] = 'website';
342 $this->seoData['published_time'] = date('c');
343 $this->seoData['modified_time'] = date('c');
344 $this->seoData['author'] = $this->sanitizeText(get_bloginfo('name'));
345 $this->seoData['publisher'] = $this->sanitizeText(get_bloginfo('name'));
346
347 } catch (\Exception $e) {
348 error_log('Yatra SEO Service collectTripArchiveData Error: ' . $e->getMessage());
349 $this->setDefaultSEOData();
350 }
351 }
352
353 private function collectDestinationListingData(): void
354 {
355 $this->seoData['title'] = $this->sanitizeText(\__('All Destinations', 'yatra'));
356 $this->seoData['description'] = $this->sanitizeText(
357 \__('Explore destinations we visit and find trips that match where you want to go.', 'yatra')
358 );
359 $this->seoData['keywords'] = '';
360 $this->seoData['image'] = '';
361 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
362 $this->seoData['type'] = 'website';
363 $this->seoData['published_time'] = \date('c');
364 $this->seoData['modified_time'] = \date('c');
365 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
366 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
367 }
368
369 private function collectActivityListingData(): void
370 {
371 $this->seoData['title'] = $this->sanitizeText(\__('All Activities', 'yatra'));
372 $this->seoData['description'] = $this->sanitizeText(
373 \__('Browse experiences and find trips built around the activities you love.', 'yatra')
374 );
375 $this->seoData['keywords'] = '';
376 $this->seoData['image'] = '';
377 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
378 $this->seoData['type'] = 'website';
379 $this->seoData['published_time'] = \date('c');
380 $this->seoData['modified_time'] = \date('c');
381 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
382 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
383 }
384
385 private function collectCategoryListingData(): void
386 {
387 $this->seoData['title'] = $this->sanitizeText(\__('Trip Categories', 'yatra'));
388 $this->seoData['description'] = $this->sanitizeText(
389 \__('Browse trips by style or theme—adventure, family, luxury, and more.', 'yatra')
390 );
391 $this->seoData['keywords'] = '';
392 $this->seoData['image'] = '';
393 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
394 $this->seoData['type'] = 'website';
395 $this->seoData['published_time'] = \date('c');
396 $this->seoData['modified_time'] = \date('c');
397 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
398 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
399 }
400
401 /**
402 * Collect SEO data for destination page
403 */
404 private function collectDestinationData(): void
405 {
406 if (!$this->pageObject) {
407 return;
408 }
409
410 $destination = $this->pageObject;
411 $metadata = [];
412 if (!empty($destination->metadata)) {
413 if (\is_array($destination->metadata)) {
414 $metadata = $destination->metadata;
415 } else {
416 $maybe = \maybe_unserialize($destination->metadata);
417 $metadata = \is_array($maybe) ? $maybe : [];
418 }
419 }
420
421 $title = $metadata['seo_title'] ?? $destination->name ?? '';
422 $this->seoData['title'] = $this->sanitizeText((string) $title);
423 $descRaw = $metadata['seo_description'] ?? $destination->description ?? '';
424 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
425 $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
426
427 // Get featured image or gallery image
428 $this->seoData['image'] = $destination->featured_image_url ??
429 ($destination->gallery_images[0] ?? '');
430
431 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
432
433 $this->seoData['type'] = 'place';
434 $this->seoData['published_time'] = date('c', strtotime($destination->created_at ?? 'now'));
435 $this->seoData['modified_time'] = date('c', strtotime($destination->updated_at ?? 'now'));
436 $this->seoData['author'] = get_bloginfo('name');
437 $this->seoData['publisher'] = get_bloginfo('name');
438 }
439
440 /**
441 * Collect SEO data for activity page
442 */
443 private function collectActivityData(): void
444 {
445 if (!$this->pageObject) {
446 return;
447 }
448
449 $activity = $this->pageObject;
450 $metadata = [];
451 if (!empty($activity->metadata)) {
452 if (\is_array($activity->metadata)) {
453 $metadata = $activity->metadata;
454 } else {
455 $maybe = \maybe_unserialize($activity->metadata);
456 $metadata = \is_array($maybe) ? $maybe : [];
457 }
458 }
459
460 $this->seoData['title'] = $this->sanitizeText((string) ($metadata['seo_title'] ?? $activity->name ?? ''));
461 $descRaw = $metadata['seo_description'] ?? $activity->description ?? '';
462 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
463 $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
464
465 // Get featured image or gallery image
466 $this->seoData['image'] = $activity->featured_image_url ??
467 ($activity->gallery_images[0] ?? '');
468
469 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
470
471 $this->seoData['type'] = 'article';
472 $this->seoData['published_time'] = date('c', strtotime($activity->created_at ?? 'now'));
473 $this->seoData['modified_time'] = date('c', strtotime($activity->updated_at ?? 'now'));
474 $this->seoData['author'] = get_bloginfo('name');
475 $this->seoData['publisher'] = get_bloginfo('name');
476 }
477
478 /**
479 * Collect SEO data for category page
480 */
481 private function collectCategoryData(): void
482 {
483 if (!$this->pageObject) {
484 return;
485 }
486
487 $category = $this->pageObject;
488 $metadata = [];
489 if (!empty($category->metadata)) {
490 if (\is_array($category->metadata)) {
491 $metadata = $category->metadata;
492 } else {
493 $maybe = \maybe_unserialize($category->metadata);
494 $metadata = \is_array($maybe) ? $maybe : [];
495 }
496 }
497
498 $this->seoData['title'] = $this->sanitizeText((string) ($metadata['seo_title'] ?? $category->name ?? ''));
499 $descRaw = $metadata['seo_description'] ?? $category->description ?? '';
500 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
501 $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
502
503 // Get featured image or gallery image
504 $this->seoData['image'] = $category->featured_image_url ??
505 ($category->gallery_images[0] ?? '');
506
507 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
508
509 $this->seoData['type'] = 'article';
510 $this->seoData['published_time'] = date('c', strtotime($category->created_at ?? 'now'));
511 $this->seoData['modified_time'] = date('c', strtotime($category->updated_at ?? 'now'));
512 $this->seoData['author'] = get_bloginfo('name');
513 $this->seoData['publisher'] = get_bloginfo('name');
514 }
515
516 /**
517 * Collect SEO data for single trip page
518 */
519 private function collectTripData(): void
520 {
521 if (!$this->pageObject) {
522 return;
523 }
524
525 $trip = $this->pageObject;
526
527 $customTitle = trim((string) ($trip->meta_title ?? ''));
528 $displayTitle = '';
529 if (\is_object($trip) && \method_exists($trip, 'getTitle')) {
530 $displayTitle = $this->sanitizeText($trip->getTitle());
531 } else {
532 $displayTitle = $this->sanitizeText((string) ($trip->title ?? $trip->name ?? ''));
533 }
534
535 $this->seoData['title'] = $customTitle !== ''
536 ? $this->sanitizeText($customTitle)
537 : $displayTitle;
538
539 $customDesc = trim((string) ($trip->meta_description ?? ''));
540 $fallbackDesc = '';
541 if (\is_object($trip) && \method_exists($trip, 'getShortDescription')) {
542 $short = $trip->getShortDescription();
543 if (!empty($short)) {
544 $fallbackDesc = $this->sanitizeText(\wp_strip_all_tags((string) $short));
545 }
546 }
547 if ($fallbackDesc === '' && !empty($trip->description)) {
548 $fallbackDesc = $this->sanitizeText(
549 \wp_trim_words(\wp_strip_all_tags((string) $trip->description), 40, '…')
550 );
551 }
552 if ($fallbackDesc === '') {
553 $fallbackDesc = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
554 }
555 $this->seoData['description'] = $customDesc !== ''
556 ? $this->sanitizeText($customDesc)
557 : $fallbackDesc;
558
559 $customKeywords = trim((string) ($trip->meta_keywords ?? ''));
560 $this->seoData['keywords'] = $customKeywords !== ''
561 ? $this->sanitizeText($customKeywords)
562 : $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
563
564 $this->seoData['image'] = $this->resolveTripSeoImageUrl($trip);
565
566 $this->seoData['url'] = function_exists('yatra_get_trip_permalink')
567 ? yatra_get_trip_permalink($trip)
568 : home_url('/' . SettingsService::getTripBase() . '/' . ($trip->slug ?? ''));
569
570 $this->seoData['type'] = 'article';
571 $this->seoData['published_time'] = date('c', strtotime($trip->created_at ?? 'now'));
572 $this->seoData['modified_time'] = date('c', strtotime($trip->updated_at ?? 'now'));
573 $this->seoData['author'] = get_bloginfo('name');
574 $this->seoData['publisher'] = get_bloginfo('name');
575 }
576
577 /**
578 * Resolve share/SEO image URL for a trip (featured, gallery, then global trip SEO image setting).
579 *
580 * @param object $trip
581 */
582 private function resolveTripSeoImageUrl(object $trip): string
583 {
584 $raw = '';
585 if (!empty($trip->featured_image_url)) {
586 $raw = (string) $trip->featured_image_url;
587 } elseif (!empty($trip->gallery_images) && \is_array($trip->gallery_images)) {
588 $first = $trip->gallery_images[0] ?? null;
589 if (\is_string($first)) {
590 $raw = $first;
591 } elseif (\is_array($first)) {
592 $raw = (string) ($first['url'] ?? $first['src'] ?? '');
593 }
594 }
595
596 if ($raw === '') {
597 $imageId = SettingsService::getInt('seo_trip_meta_image', 0);
598 if ($imageId > 0) {
599 $url = \wp_get_attachment_url($imageId);
600 if ($url && \filter_var($url, FILTER_VALIDATE_URL)) {
601 $raw = $url;
602 }
603 }
604 }
605
606 return $raw !== '' ? $this->validateUrl($raw) : '';
607 }
608
609 /**
610 * Output basic meta tags
611 */
612 private function outputBasicMetaTags(): void
613 {
614 if (!empty($this->seoData['description'])) {
615 echo '<meta name="description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
616 }
617
618 if (!empty($this->seoData['keywords'])) {
619 echo '<meta name="keywords" content="' . esc_attr($this->seoData['keywords']) . '">' . "\n";
620 }
621 }
622
623 /**
624 * Output Open Graph meta tags
625 */
626 private function outputOpenGraphTags(): void
627 {
628 echo '<meta property="og:locale" content="en_US">' . "\n";
629 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
630 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
631 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
632 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
633 echo '<meta property="og:url" content="' . esc_url($this->seoData['url']) . '">' . "\n";
634
635 if (!empty($this->seoData['image'])) {
636 echo '<meta property="og:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
637 echo '<meta property="og:image:width" content="1200">' . "\n";
638 echo '<meta property="og:image:height" content="630">' . "\n";
639 echo '<meta property="og:image:alt" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
640 }
641
642 if (!empty($this->seoData['published_time'])) {
643 echo '<meta property="article:published_time" content="' . esc_attr($this->seoData['published_time']) . '">' . "\n";
644 }
645
646 if (!empty($this->seoData['modified_time'])) {
647 echo '<meta property="article:modified_time" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
648 }
649 }
650
651 /**
652 * Output Twitter Card meta tags
653 */
654 private function outputTwitterCardTags(): void
655 {
656 echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
657 echo '<meta name="twitter:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
658 echo '<meta name="twitter:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
659
660 if (!empty($this->seoData['image'])) {
661 echo '<meta name="twitter:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
662 }
663 }
664
665 /**
666 * Output advanced meta tags
667 */
668 private function outputAdvancedMetaTags(): void
669 {
670 echo '<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
671 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
672 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
673 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
674 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
675 echo '<link rel="alternate" hreflang="en-US" href="' . esc_url($this->seoData['url']) . '">' . "\n";
676 echo '<meta name="revisit-after" content="7 days">' . "\n";
677 echo '<meta name="distribution" content="global">' . "\n";
678 echo '<meta name="rating" content="general">' . "\n";
679 }
680
681 /**
682 * rel="prev" / rel="next" for paginated trip archive and taxonomy listings (Google-supported series hints).
683 */
684 private function outputPaginationRelLinks(): void
685 {
686 $prevUrl = '';
687 $nextUrl = '';
688
689 switch ($this->pageType) {
690 case self::PAGE_TYPE_TRIP_ARCHIVE:
691 $ctx = $GLOBALS['yatra_trip_listing_context'] ?? null;
692 if (!\is_array($ctx)) {
693 return;
694 }
695 $pagination = $ctx['pagination'] ?? null;
696 if (!\is_array($pagination)) {
697 return;
698 }
699 if (!empty($pagination['prev_url']) && \is_string($pagination['prev_url'])) {
700 $prevUrl = $pagination['prev_url'];
701 }
702 if (!empty($pagination['next_url']) && \is_string($pagination['next_url'])) {
703 $nextUrl = $pagination['next_url'];
704 }
705 break;
706
707 case self::PAGE_TYPE_DESTINATION:
708 case self::PAGE_TYPE_ACTIVITY:
709 case self::PAGE_TYPE_CATEGORY:
710 $td = $GLOBALS['yatra_taxonomy_data'] ?? null;
711 if (!$td || !\is_object($td)) {
712 return;
713 }
714 $total = \max(1, (int) ($td->trips_pages ?? 1));
715 $current = (int) ($td->trips_current_page ?? 1);
716 if ($current < 1) {
717 $current = 1;
718 }
719 $current = \min($current, $total);
720 if (!\function_exists('yatra_build_current_request_paged_url')) {
721 return;
722 }
723 if ($current > 1) {
724 $prevUrl = (string) yatra_build_current_request_paged_url($current - 1);
725 }
726 if ($current < $total) {
727 $nextUrl = (string) yatra_build_current_request_paged_url($current + 1);
728 }
729 break;
730
731 case self::PAGE_TYPE_DESTINATION_LISTING:
732 case self::PAGE_TYPE_ACTIVITY_LISTING:
733 case self::PAGE_TYPE_CATEGORY_LISTING:
734 $meta = $GLOBALS['yatra_archive_browse_pagination'] ?? null;
735 if (!\is_array($meta)) {
736 return;
737 }
738 $total = \max(1, (int) ($meta['total_pages'] ?? 1));
739 $current = (int) ($meta['current_page'] ?? 1);
740 if ($current < 1) {
741 $current = 1;
742 }
743 $current = \min($current, $total);
744 if (!\function_exists('yatra_build_archive_listing_url')) {
745 return;
746 }
747 if ($current > 1) {
748 $prevUrl = (string) yatra_build_archive_listing_url($current - 1);
749 }
750 if ($current < $total) {
751 $nextUrl = (string) yatra_build_archive_listing_url($current + 1);
752 }
753 break;
754
755 default:
756 return;
757 }
758
759 if ($prevUrl !== '') {
760 echo '<link rel="prev" href="' . esc_url($prevUrl) . '">' . "\n";
761 }
762 if ($nextUrl !== '') {
763 echo '<link rel="next" href="' . esc_url($nextUrl) . '">' . "\n";
764 }
765 }
766
767 /**
768 * Output Schema markup
769 */
770 private function outputSchemaMarkup(): void
771 {
772 $schema = $this->generateSchemaMarkup();
773 if (!empty($schema)) {
774 echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
775 }
776 }
777
778 /**
779 * Generate schema markup based on page type
780 */
781 private function generateSchemaMarkup(): array
782 {
783 $baseSchema = [
784 '@context' => 'https://schema.org',
785 'name' => $this->seoData['title'],
786 'description' => $this->seoData['description'],
787 'url' => $this->seoData['url'],
788 'publisher' => [
789 '@type' => 'Organization',
790 'name' => get_bloginfo('name'),
791 'url' => home_url()
792 ],
793 'dateModified' => $this->seoData['modified_time'],
794 'datePublished' => $this->seoData['published_time'],
795 'inLanguage' => 'en-US',
796 'isPartOf' => [
797 '@type' => 'WebSite',
798 'name' => get_bloginfo('name'),
799 'url' => home_url()
800 ]
801 ];
802
803 // Add image if available
804 if (!empty($this->seoData['image'])) {
805 $baseSchema['image'] = $this->seoData['image'];
806 }
807
808 switch ($this->pageType) {
809 case self::PAGE_TYPE_TRIP_ARCHIVE:
810 case self::PAGE_TYPE_DESTINATION_LISTING:
811 case self::PAGE_TYPE_ACTIVITY_LISTING:
812 case self::PAGE_TYPE_CATEGORY_LISTING:
813 return $this->generateCollectionPageSchema($baseSchema);
814 case self::PAGE_TYPE_DESTINATION:
815 return $this->generatePlaceSchema($baseSchema);
816 case self::PAGE_TYPE_ACTIVITY:
817 case self::PAGE_TYPE_CATEGORY:
818 case self::PAGE_TYPE_TRIP:
819 return $this->generateArticleSchema($baseSchema);
820 default:
821 return $baseSchema;
822 }
823 }
824
825 /**
826 * Generate CollectionPage schema for archive pages
827 */
828 private function generateCollectionPageSchema(array $baseSchema): array
829 {
830 return array_merge($baseSchema, [
831 '@type' => 'CollectionPage',
832 'mainEntity' => [
833 '@type' => 'ItemList',
834 'numberOfItems' => 0, // This should be dynamically populated
835 'itemListElement' => []
836 ]
837 ]);
838 }
839
840 /**
841 * Generate Place schema for destination pages
842 */
843 private function generatePlaceSchema(array $baseSchema): array
844 {
845 return array_merge($baseSchema, [
846 '@type' => 'Place',
847 'address' => $this->pageObject->address ?? '',
848 'geo' => [
849 '@type' => 'GeoCoordinates',
850 'latitude' => $this->pageObject->latitude ?? '',
851 'longitude' => $this->pageObject->longitude ?? ''
852 ]
853 ]);
854 }
855
856 /**
857 * Generate Article schema for content pages
858 */
859 private function generateArticleSchema(array $baseSchema): array
860 {
861 return array_merge($baseSchema, [
862 '@type' => 'Article',
863 'headline' => $this->seoData['title'],
864 'author' => [
865 '@type' => 'Organization',
866 'name' => get_bloginfo('name')
867 ]
868 ]);
869 }
870
871 /**
872 * Truncate text to specified length
873 */
874 private function truncateText(string $text, int $length): string
875 {
876 if (strlen($text) <= $length) {
877 return $text;
878 }
879
880 return substr($text, 0, $length - 3) . '...';
881 }
882 }
883