PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5.1
Yatra – Travel Booking & Tour Operator Software v3.0.5.1
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.5.1, at app/Services/SEOService.php

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