PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | app/Providers/AdminAssetsProvider.php +313 -18 3.0.2.7 → 3.0.17 View file →
@@ -27,15 +27,28 @@
27 27 $capabilities = [];
28 28 if ($current_user->ID > 0) {
29 29 $user_caps = $current_user->allcaps;
30 30 foreach ($user_caps as $cap => $has_cap) {
31 - if ($has_cap && strpos((string) $cap, 'yatra_') === 0) {
32 - $capabilities[$cap] = true;
31 + if (!$has_cap) continue;
32 + // Mirror every `yatra_*` cap into the JS-side map (these
33 + // are what React's `can()` checks against). Also
34 + // explicitly include `manage_options` so the React-side
35 + // admin fallback has a server-confirmed signal even on
36 + // exotic installs where `isWpAdmin` or `roles` were
37 + // filtered out by a third-party plugin.
38 + $capStr = (string) $cap;
39 + if (strpos($capStr, 'yatra_') === 0 || $capStr === 'manage_options') {
40 + $capabilities[$capStr] = true;
33 41 }
34 42 }
35 43 }
36 44
37 45 return apply_filters('yatra_admin_localized_data', [
46 + 'timeZoneIdentifiers' => self::buildTimezoneIdentifierList(),
47 + 'wordPressTimezone' => function_exists('wp_timezone_string')
48 + ? (string) wp_timezone_string()
49 + : 'UTC',
50 + 'timezone' => \Yatra\Services\SettingsService::getString('timezone', 'UTC'),
38 51 'apiUrl' => rest_url('yatra/v1'),
39 52 'licenseStatus' => (function () {
40 53 $all = get_option('yatra_license', []);
41 54 $status = $all['yatra-pro']['status'] ?? 'inactive';
@@ -50,20 +63,104 @@
50 63 'currentUserAvatar' => get_avatar($current_user->ID, 96),
51 64 'siteUrl' => home_url(),
52 65 'adminUrl' => admin_url('admin.php'),
53 66 'pluginUrl' => YATRA_PLUGIN_URL,
67 + // Public URL of the Yatra sitemap (handles plain vs pretty
68 + // permalinks), shown in the SEO settings tab.
69 + 'sitemapUrl' => \Yatra\Sitemap\SitemapRouter::sitemapUrl(),
70 + // Brand-name and brand-logo helpers are filter-backed (defaults
71 + // wired in includes/helpers.php). Pro's WhiteLabel module
72 + // overrides the filters when Agency white-label is active.
54 73 'brandLogoUrl' => function_exists('yatra_get_brand_icon_url') ? yatra_get_brand_icon_url() : '',
74 + 'brandName' => function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra',
75 + // White-label-specific window.yatraAdmin keys (brandMenuOverrides,
76 + // brandMenuOrder, brandUiChrome, brandPrimaryColor) are injected
77 + // by Pro via the `yatra_admin_localized_data` filter applied at
78 + // the bottom of this method. They are NOT set here because option
79 + // storage is owned by Pro's WhiteLabel module.
55 80 'permalinkStructure' => (get_option('permalink_structure') ?: '') ?: 'plain',
56 81 'tripBase' => \Yatra\Services\SettingsService::getTripBase(),
57 82 'bookingBase' => \Yatra\Services\SettingsService::getBookingBase(),
58 83 'capabilities' => $capabilities,
59 84 'roles' => $current_user->roles,
85 + // Cap-gating fallback flag. ALWAYS injected (not just by the
86 + // Team module) because the React `usePermissions.can()` helper
87 + // uses it as the last-resort allow for site owners: anyone
88 + // with `manage_options` passes any cap check, mirroring the
89 + // server-side admin fallback in Team's Capabilities filter.
90 + //
91 + // Without this, free-plugin installs (or Pro installs where
92 + // Team is off) silently fail every `can("yatra_*")` check —
93 + // even for site owners — because the cap isn't on the
94 + // administrator role record. The Team module overwrites
95 + // this same key when active; semantics are identical, so
96 + // the overwrite is safe.
97 + 'isWpAdmin' => current_user_can('manage_options'),
60 98 'isPro' => defined('YATRA_PRO_VERSION'),
99 + // Agency-tier flag — drives the sidebar's White Label entry visibility
100 + // and any other Agency-only UI affordances. Pro registers the filter
101 + // unconditionally so the value is always trustworthy.
102 + 'isAgency' => (bool) apply_filters('yatra_is_agency_active', false),
103 + // AI-eligibility flag (Growth + Agency). Drives the AI Assistant
104 + // sidebar entry visibility and the per-field sparkle affordances
105 + // in the trip / SEO editors.
106 + 'isAiEligible' => (bool) apply_filters('yatra_is_ai_eligible', false),
107 + 'whiteLabelEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
108 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('white_label')
109 + : false,
110 + 'aiAssistantEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
111 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('ai_assistant')
112 + : false,
113 + 'whatsappEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
114 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('whatsapp')
115 + : false,
116 + 'channelManagerEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
117 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('channel_manager')
118 + : false,
119 + 'webhooksEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
120 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('webhooks')
121 + : false,
122 + // Settings → Pricing (Discount Stacking) drives off these
123 + // two. Setting them here (free plugin, AdminAssetsProvider)
124 + // matches the pattern used by every other Pro-module flag
125 + // above and decouples the React UI from Pro module boot
126 + // timing — Pro's init.php is conditionally loaded by
127 + // ProModuleManager only when the module is enabled, so any
128 + // filter-based exposure could fail silently if boot order
129 + // shifts. Reading from the canonical ModuleManager here is
130 + // the source of truth.
131 + 'dynamicPricingEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
132 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('dynamic_pricing')
133 + : false,
134 + 'advancedDiscountEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
135 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('advanced_discount')
136 + : false,
137 + // Single source of truth for every country dropdown in the
138 + // React admin. Pulled from the canonical FormatHelper —
139 + // operators that want a curated or reordered list apply
140 + // the `yatra_countries_list` filter once and it propagates
141 + // to every dropdown automatically.
142 + 'countries' => class_exists('\\Yatra\\Helpers\\FormatHelper')
143 + ? \Yatra\Helpers\FormatHelper::getCountries()
144 + : [],
145 + 'customLandingPagesModuleEnabled' => class_exists('\\Yatra\\Core\\Modules\\ModuleManager')
146 + ? \Yatra\Core\Modules\ModuleManager::isModuleEnabled('custom_landing_pages')
147 + : false,
148 + // Per-trip Deposit & Payment Terms is a Pro feature (FlexiblePayments).
149 + // Default false; Pro's FlexiblePaymentsModule::addAdminData() flips this
150 + // to true via the `yatra_admin_localized_data` filter when active, and
151 + // the React TripForm hides/shows the section based on this flag.
152 + 'flexiblePaymentsEnabled' => false,
61 153 'version' => defined('YATRA_VERSION') ? YATRA_VERSION : '1.0.0',
62 154 'proVersion' => defined('YATRA_PRO_VERSION') ? YATRA_PRO_VERSION : null,
63 155
64 156 'locale' => get_locale(),
65 157 'currency' => \Yatra\Services\SettingsService::getCurrency(),
158 + 'currencyPosition' => \Yatra\Services\SettingsService::getString('currency_position', 'left'),
159 + 'currency_position' => \Yatra\Services\SettingsService::getString('currency_position', 'left'),
160 + 'decimalPlaces' => \Yatra\Services\SettingsService::getPriceDecimals(),
161 + 'thousandSeparator' => \Yatra\Services\SettingsService::getString('thousand_separator', ','),
162 + 'decimalSeparator' => \Yatra\Services\SettingsService::getString('decimal_separator', '.'),
66 163 'date_format' => \Yatra\Services\SettingsService::get('date_format', 'Y-m-d'),
67 164 'time_format' => \Yatra\Services\SettingsService::get('time_format', 'H:i'),
68 165 'geocodingNonce' => wp_create_nonce('yatra_geocoding_nonce'),
69 166 'ajaxUrl' => admin_url('admin-ajax.php'),
@@ -70,8 +167,33 @@
70 167 ]);
71 168 }
72 169
73 170 /**
171 + * Sorted IANA identifiers for the admin timezone control (matches PHP {@see DateTimeZone}).
172 + *
173 + * @return list<string>
174 + */
175 + private static function buildTimezoneIdentifierList(): array
176 + {
177 + if (!function_exists('timezone_identifiers_list')) {
178 + return ['UTC'];
179 + }
180 +
181 + $ids = timezone_identifiers_list();
182 + if (!is_array($ids) || $ids === []) {
183 + return ['UTC'];
184 + }
185 +
186 + $ids = array_values(array_filter($ids, static function ($id): bool {
187 + return is_string($id) && $id !== '';
188 + }));
189 +
190 + sort($ids, SORT_STRING);
191 +
192 + return $ids;
193 + }
194 +
195 + /**
74 196 * Enqueue all admin assets
75 197 *
76 198 * @param string $hook Current admin page hook
77 199 * @return void
@@ -206,9 +328,20 @@
206 328 * @return void
207 329 */
208 330 private function enqueueAdminReactCss(): void
209 331 {
332 + $faPath = YATRA_PLUGIN_PATH . 'assets/vendor/fontawesome/css/all.min.css';
333 + if (file_exists($faPath)) {
334 + wp_enqueue_style(
335 + 'yatra-fontawesome-6-admin',
336 + YATRA_PLUGIN_URL . 'assets/vendor/fontawesome/css/all.min.css',
337 + [],
338 + '6.7.2.' . filemtime($faPath)
339 + );
340 + }
341 +
210 342 $basePath = YATRA_PLUGIN_PATH . 'assets/admin/dist/css/';
343 + $faHandle = file_exists($faPath) ? 'yatra-fontawesome-6-admin' : false;
211 344
212 345 // React vendor CSS (contains react-draft-wysiwyg CSS)
213 346 $reactVendorCss = $basePath . 'react-vendor.css';
214 347 if (file_exists($reactVendorCss)) {
@@ -215,9 +348,9 @@
215 348 $cssVersion = YATRA_VERSION . '.' . filemtime($reactVendorCss);
216 349 wp_enqueue_style(
217 350 'yatra-react-vendor',
218 351 YATRA_PLUGIN_URL . 'assets/admin/dist/css/react-vendor.css',
219 - [],
352 + $faHandle ? [$faHandle] : [],
220 353 $cssVersion
221 354 );
222 355 }
223 356
@@ -275,9 +408,17 @@
275 408 // Use built assets in production
276 409 $appJs = YATRA_PLUGIN_PATH . 'assets/admin/dist/js/app.js';
277 410
278 411 if (file_exists($appJs)) {
279 - $jsVersion = YATRA_VERSION . '.' . filemtime($appJs) . '.view-icon-fix.' . time() . '.' . microtime(true);
412 + // Version on the plugin version + the bundle's own mtime. That
413 + // already changes on every update or rebuild, which is exactly
414 + // when the cache must be busted.
415 + //
416 + // This previously appended time() . microtime(true), making the
417 + // URL unique on every single request — so the ~3 MB admin bundle
418 + // was re-downloaded on every admin page view and could never be
419 + // cached by the browser.
420 + $jsVersion = YATRA_VERSION . '.' . filemtime($appJs);
280 421
281 422 $localized_data = $this->buildAdminLocalizedData();
282 423
283 424 // Enqueue our script with media library as dependency
@@ -298,11 +439,32 @@
298 439 $jsVersion,
299 440 true
300 441 );
301 442
443 + // The bundle calls the global wp.i18n.__() (it never ships its
444 + // own copy), and scripts/extract-js-pot.mjs writes every admin
445 + // string's `#:` reference as this bundle's path precisely so
446 + // WordPress's md5(handle src) JSON lookup matches. This call is
447 + // the missing last link: it tells WordPress to load
448 + // i18n/languages/yatra-{locale}-{md5}.json (or the copy under
449 + // WP_LANG_DIR/plugins) for the admin UI. Without it, translated
450 + // admin strings never reach the SPA. Mirrors FrontendAssetsProvider.
451 + if (function_exists('wp_set_script_translations')) {
452 + wp_set_script_translations('yatra-admin', 'yatra', YATRA_PLUGIN_PATH . 'i18n/languages');
453 + $this->backfillScriptTranslations('yatra-admin');
454 + }
455 +
302 456 // Localize script data
303 457 wp_localize_script('yatra-admin', 'yatraAdmin', $localized_data);
304 458
459 + // Phone dataset for admin displays (flag + dial-code detection of
460 + // stored "+<code><number>" values in booking details).
461 + wp_localize_script('yatra-admin', 'yatraPhoneData', [
462 + 'countries' => \Yatra\Helpers\FormatHelper::getPhoneCountries(),
463 + 'priority' => \Yatra\Helpers\FormatHelper::getPhonePriority(),
464 + 'flagBase' => YATRA_PLUGIN_URL . 'assets/img/flags/',
465 + ]);
466 +
305 467 // Start fetching the ES module as early as possible (helps shorten white/splash time before React runs)
306 468 $app_js_url = YATRA_PLUGIN_URL . 'assets/admin/dist/js/app.js';
307 469 add_action('admin_head', static function () use ($app_js_url, $jsVersion): void {
308 470 $href = esc_url(add_query_arg('ver', rawurlencode((string) $jsVersion), $app_js_url));
@@ -319,20 +481,23 @@
319 481 * @return bool
320 482 */
321 483 private function isViteDevServerRunning(string $url): bool
322 484 {
323 - // Check the actual asset URL, not the root
485 + // Check the actual asset URL, not the root. Uses the WP HTTP API
486 + // (not raw cURL) per WP.org guidelines. Only ever called in dev mode
487 + // (WP_DEBUG && YATRA_DEV_MODE), so it never runs on production loads.
324 488 $assetUrl = $url . '/assets/admin/dist/js/app.js';
325 - $ch = curl_init($assetUrl);
326 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
327 - curl_setopt($ch, CURLOPT_TIMEOUT, 2); // 2 second timeout
328 - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); // 1 second connection timeout
329 - curl_setopt($ch, CURLOPT_NOBODY, true); // HEAD request only
330 - curl_exec($ch);
331 - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
332 - curl_close($ch);
333 -
334 - return $httpCode === 200;
489 +
490 + $response = wp_remote_head($assetUrl, [
491 + 'timeout' => 2,
492 + 'redirection' => 0,
493 + ]);
494 +
495 + if (is_wp_error($response)) {
496 + return false;
497 + }
498 +
499 + return (int) wp_remote_retrieve_response_code($response) === 200;
335 500 }
336 501
337 502 /**
338 503 * Add inline script for media library compatibility
@@ -352,20 +517,150 @@
352 517 * @return void
353 518 */
354 519 private function loadWordPressTranslations(): void
355 520 {
356 - // Use WordPress built-in function to load script translations
357 - // Specify the path where WordPress should look for JSON translation files
521 + // Use WordPress built-in function to load script translations.
522 + // The third argument MUST be an absolute path to the directory
523 + // that contains the per-locale .json translation files.
524 + //
525 + // Previously this passed YATRA_PLUGIN_FILE — i.e. the main
526 + // plugin PHP FILE path, not its directory. Appending
527 + // "/i18n/languages" yielded ".../plugin/yatra.php/i18n/languages",
528 + // a path that doesn't exist, so WordPress silently fell back to
529 + // shipping source-English strings to the React admin regardless
530 + // of the operator's WP locale.
531 + //
532 + // Use YATRA_PLUGIN_PATH (the directory, ending in /) instead,
533 + // matching the block-editor side that has always worked.
534 + //
535 + // The actual JSON file shipped here is generated at BUILD time
536 + // by scripts/build-translation-json.mjs from each locale's .po
537 + // file. That script writes ONE consolidated JSON per locale
538 + // named `yatra-{locale}-{md5(bundle src path)}.json`, so
539 + // WordPress's native script-translation loader finds it on
540 + // first try — no runtime filter / merge needed.
358 541 if (function_exists('wp_set_script_translations')) {
359 - wp_set_script_translations('yatra-admin', 'yatra', YATRA_PLUGIN_FILE . '/i18n/languages');
542 + wp_set_script_translations('yatra-admin', 'yatra', YATRA_PLUGIN_PATH . 'i18n/languages');
360 543 }
361 544 }
362 545
546 +
363 547 /**
364 548 * Enqueue setup wizard assets
365 549 *
366 550 * @return void
367 551 */
552 + /**
553 + * Feed the admin bundle its translations when no JSON file exists.
554 + *
555 + * wp_set_script_translations() can only read a `yatra-{locale}-{md5}.json`
556 + * file. WordPress.org language packs ship one, so translations from
557 + * translate.wordpress.org simply work — but a site translated by hand, with
558 + * Loco Translate or a .po/.mo dropped into wp-content/languages, has only
559 + * the PHP catalogue. For those sites every string in the React admin stayed
560 + * in English no matter how complete the translation was, which looked like
561 + * the plugin ignoring the translation altogether.
562 + *
563 + * The strings are the same ones the PHP catalogue already holds, so they are
564 + * handed to wp.i18n directly. Core is asked first and left in charge
565 + * whenever it can find a JSON file: that path is cached by the browser as a
566 + * separate request, and this one is not.
567 + *
568 + * Nothing is emitted on an untranslated site — the map comes back empty and
569 + * English sites carry no extra weight.
570 + */
571 + private function backfillScriptTranslations(string $handle): void
572 + {
573 + if (!function_exists('load_script_textdomain')) {
574 + return;
575 + }
576 +
577 + // A real JSON file beats this: let WordPress load it as it normally would.
578 + if (load_script_textdomain($handle, 'yatra', YATRA_PLUGIN_PATH . 'i18n/languages')) {
579 + return;
580 + }
581 +
582 + $localeData = $this->localeDataFromTextdomain('yatra');
583 + if ($localeData === []) {
584 + return;
585 + }
586 +
587 + wp_add_inline_script(
588 + $handle,
589 + 'wp.i18n.setLocaleData(' . wp_json_encode($localeData) . ', "yatra");',
590 + 'before'
591 + );
592 + }
593 +
594 + /**
595 + * The loaded PHP catalogue, in the shape wp.i18n.setLocaleData() expects.
596 + *
597 + * Entries that were never translated are left out: they would only restate
598 + * the English the bundle already carries. Contexts use the same NUL
599 + * separator Jed and gettext use, so _x() resolves too.
600 + *
601 + * @return array<string, mixed>
602 + */
603 + private function localeDataFromTextdomain(string $domain): array
604 + {
605 + $translations = get_translations_for_domain($domain);
606 + if (!is_object($translations)) {
607 + return [];
608 + }
609 + // Deliberately duck-typed, and deliberately not isset()/??. WordPress
610 + // returns Translations, NOOP_Translations or — since the performant
611 + // translations work — WP_Translations, which neither extends
612 + // Translations nor declares `entries`: it serves that property through
613 + // __get() and defines no __isset(), so both instanceof and isset()
614 + // report nothing is there and quietly disable this fallback. Only a
615 + // direct read reaches the magic getter.
616 + if (!property_exists($translations, 'entries') && !method_exists($translations, '__get')) {
617 + return [];
618 + }
619 +
620 + $entries = $translations->entries;
621 + if (!is_array($entries) || $entries === []) {
622 + return [];
623 + }
624 +
625 + $data = [];
626 + foreach ($entries as $entry) {
627 + if (!is_object($entry) || (string) $entry->singular === '') {
628 + continue;
629 + }
630 +
631 + $forms = array_values(array_filter(
632 + (array) $entry->translations,
633 + static fn($t) => is_string($t) && $t !== ''
634 + ));
635 + if ($forms === []) {
636 + continue;
637 + }
638 + // Untranslated entries come back as the original string.
639 + if (count($forms) === 1 && $forms[0] === $entry->singular) {
640 + continue;
641 + }
642 +
643 + $key = ($entry->context !== null && $entry->context !== '')
644 + ? $entry->context . "\u{0004}" . $entry->singular
645 + : $entry->singular;
646 +
647 + $data[$key] = $forms;
648 + }
649 +
650 + if ($data === []) {
651 + return [];
652 + }
653 +
654 + $data[''] = [
655 + 'domain' => $domain,
656 + 'lang' => determine_locale(),
657 + 'plural-forms' => 'nplurals=2; plural=(n != 1);',
658 + ];
659 +
660 + return $data;
661 + }
662 +
368 663 public function enqueueSetupWizardAssets(): void
369 664 {
370 665 // Enqueue setup wizard CSS
371 666 $cssPath = YATRA_PLUGIN_PATH . 'assets/admin/css/setup-wizard.css';