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

907 lines 33.9 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 * Output basic meta tags
635 */
636 private function outputBasicMetaTags(): void
637 {
638 if (!empty($this->seoData['description'])) {
639 echo '<meta name="description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
640 }
641
642 if (!empty($this->seoData['keywords'])) {
643 echo '<meta name="keywords" content="' . esc_attr($this->seoData['keywords']) . '">' . "\n";
644 }
645 }
646
647 /**
648 * Output Open Graph meta tags
649 */
650 private function outputOpenGraphTags(): void
651 {
652 echo '<meta property="og:locale" content="en_US">' . "\n";
653 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
654 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
655 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
656 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
657 echo '<meta property="og:url" content="' . esc_url($this->seoData['url']) . '">' . "\n";
658
659 if (!empty($this->seoData['image'])) {
660 echo '<meta property="og:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
661 echo '<meta property="og:image:width" content="1200">' . "\n";
662 echo '<meta property="og:image:height" content="630">' . "\n";
663 echo '<meta property="og:image:alt" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
664 }
665
666 if (!empty($this->seoData['published_time'])) {
667 echo '<meta property="article:published_time" content="' . esc_attr($this->seoData['published_time']) . '">' . "\n";
668 }
669
670 if (!empty($this->seoData['modified_time'])) {
671 echo '<meta property="article:modified_time" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
672 }
673 }
674
675 /**
676 * Output Twitter Card meta tags
677 */
678 private function outputTwitterCardTags(): void
679 {
680 echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
681 echo '<meta name="twitter:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
682 echo '<meta name="twitter:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
683
684 if (!empty($this->seoData['image'])) {
685 echo '<meta name="twitter:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
686 }
687 }
688
689 /**
690 * Output advanced meta tags
691 */
692 private function outputAdvancedMetaTags(): void
693 {
694 echo '<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
695 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
696 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
697 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
698 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
699 echo '<link rel="alternate" hreflang="en-US" href="' . esc_url($this->seoData['url']) . '">' . "\n";
700 echo '<meta name="revisit-after" content="7 days">' . "\n";
701 echo '<meta name="distribution" content="global">' . "\n";
702 echo '<meta name="rating" content="general">' . "\n";
703 }
704
705 /**
706 * rel="prev" / rel="next" for paginated trip archive and taxonomy listings (Google-supported series hints).
707 */
708 private function outputPaginationRelLinks(): void
709 {
710 $prevUrl = '';
711 $nextUrl = '';
712
713 switch ($this->pageType) {
714 case self::PAGE_TYPE_TRIP_ARCHIVE:
715 $ctx = $GLOBALS['yatra_trip_listing_context'] ?? null;
716 if (!\is_array($ctx)) {
717 return;
718 }
719 $pagination = $ctx['pagination'] ?? null;
720 if (!\is_array($pagination)) {
721 return;
722 }
723 if (!empty($pagination['prev_url']) && \is_string($pagination['prev_url'])) {
724 $prevUrl = $pagination['prev_url'];
725 }
726 if (!empty($pagination['next_url']) && \is_string($pagination['next_url'])) {
727 $nextUrl = $pagination['next_url'];
728 }
729 break;
730
731 case self::PAGE_TYPE_DESTINATION:
732 case self::PAGE_TYPE_ACTIVITY:
733 case self::PAGE_TYPE_CATEGORY:
734 $td = $GLOBALS['yatra_taxonomy_data'] ?? null;
735 if (!$td || !\is_object($td)) {
736 return;
737 }
738 $total = \max(1, (int) ($td->trips_pages ?? 1));
739 $current = (int) ($td->trips_current_page ?? 1);
740 if ($current < 1) {
741 $current = 1;
742 }
743 $current = \min($current, $total);
744 if (!\function_exists('yatra_build_current_request_paged_url')) {
745 return;
746 }
747 if ($current > 1) {
748 $prevUrl = (string) yatra_build_current_request_paged_url($current - 1);
749 }
750 if ($current < $total) {
751 $nextUrl = (string) yatra_build_current_request_paged_url($current + 1);
752 }
753 break;
754
755 case self::PAGE_TYPE_DESTINATION_LISTING:
756 case self::PAGE_TYPE_ACTIVITY_LISTING:
757 case self::PAGE_TYPE_CATEGORY_LISTING:
758 $meta = $GLOBALS['yatra_archive_browse_pagination'] ?? null;
759 if (!\is_array($meta)) {
760 return;
761 }
762 $total = \max(1, (int) ($meta['total_pages'] ?? 1));
763 $current = (int) ($meta['current_page'] ?? 1);
764 if ($current < 1) {
765 $current = 1;
766 }
767 $current = \min($current, $total);
768 if (!\function_exists('yatra_build_archive_listing_url')) {
769 return;
770 }
771 if ($current > 1) {
772 $prevUrl = (string) yatra_build_archive_listing_url($current - 1);
773 }
774 if ($current < $total) {
775 $nextUrl = (string) yatra_build_archive_listing_url($current + 1);
776 }
777 break;
778
779 default:
780 return;
781 }
782
783 if ($prevUrl !== '') {
784 echo '<link rel="prev" href="' . esc_url($prevUrl) . '">' . "\n";
785 }
786 if ($nextUrl !== '') {
787 echo '<link rel="next" href="' . esc_url($nextUrl) . '">' . "\n";
788 }
789 }
790
791 /**
792 * Output Schema markup
793 */
794 private function outputSchemaMarkup(): void
795 {
796 $schema = $this->generateSchemaMarkup();
797 if (!empty($schema)) {
798 echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
799 }
800 }
801
802 /**
803 * Generate schema markup based on page type
804 */
805 private function generateSchemaMarkup(): array
806 {
807 $baseSchema = [
808 '@context' => 'https://schema.org',
809 'name' => $this->seoData['title'],
810 'description' => $this->seoData['description'],
811 'url' => $this->seoData['url'],
812 'publisher' => [
813 '@type' => 'Organization',
814 'name' => get_bloginfo('name'),
815 'url' => home_url()
816 ],
817 'dateModified' => $this->seoData['modified_time'],
818 'datePublished' => $this->seoData['published_time'],
819 'inLanguage' => 'en-US',
820 'isPartOf' => [
821 '@type' => 'WebSite',
822 'name' => get_bloginfo('name'),
823 'url' => home_url()
824 ]
825 ];
826
827 // Add image if available
828 if (!empty($this->seoData['image'])) {
829 $baseSchema['image'] = $this->seoData['image'];
830 }
831
832 switch ($this->pageType) {
833 case self::PAGE_TYPE_TRIP_ARCHIVE:
834 case self::PAGE_TYPE_DESTINATION_LISTING:
835 case self::PAGE_TYPE_ACTIVITY_LISTING:
836 case self::PAGE_TYPE_CATEGORY_LISTING:
837 return $this->generateCollectionPageSchema($baseSchema);
838 case self::PAGE_TYPE_DESTINATION:
839 return $this->generatePlaceSchema($baseSchema);
840 case self::PAGE_TYPE_ACTIVITY:
841 case self::PAGE_TYPE_CATEGORY:
842 case self::PAGE_TYPE_TRIP:
843 return $this->generateArticleSchema($baseSchema);
844 default:
845 return $baseSchema;
846 }
847 }
848
849 /**
850 * Generate CollectionPage schema for archive pages
851 */
852 private function generateCollectionPageSchema(array $baseSchema): array
853 {
854 return array_merge($baseSchema, [
855 '@type' => 'CollectionPage',
856 'mainEntity' => [
857 '@type' => 'ItemList',
858 'numberOfItems' => 0, // This should be dynamically populated
859 'itemListElement' => []
860 ]
861 ]);
862 }
863
864 /**
865 * Generate Place schema for destination pages
866 */
867 private function generatePlaceSchema(array $baseSchema): array
868 {
869 return array_merge($baseSchema, [
870 '@type' => 'Place',
871 'address' => $this->pageObject->address ?? '',
872 'geo' => [
873 '@type' => 'GeoCoordinates',
874 'latitude' => $this->pageObject->latitude ?? '',
875 'longitude' => $this->pageObject->longitude ?? ''
876 ]
877 ]);
878 }
879
880 /**
881 * Generate Article schema for content pages
882 */
883 private function generateArticleSchema(array $baseSchema): array
884 {
885 return array_merge($baseSchema, [
886 '@type' => 'Article',
887 'headline' => $this->seoData['title'],
888 'author' => [
889 '@type' => 'Organization',
890 'name' => get_bloginfo('name')
891 ]
892 ]);
893 }
894
895 /**
896 * Truncate text to specified length
897 */
898 private function truncateText(string $text, int $length): string
899 {
900 if (strlen($text) <= $length) {
901 return $text;
902 }
903
904 return substr($text, 0, $length - 3) . '...';
905 }
906 }
907