| @@ -63,8 +63,11 @@ | ||
| 63 | 63 | 'currentUserAvatar' => get_avatar($current_user->ID, 96), |
| 64 | 64 | 'siteUrl' => home_url(), |
| 65 | 65 | 'adminUrl' => admin_url('admin.php'), |
| 66 | 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(), | |
| 67 | 70 | // Brand-name and brand-logo helpers are filter-backed (defaults |
| 68 | 71 | // wired in includes/helpers.php). Pro's WhiteLabel module |
| 69 | 72 | // overrides the filters when Agency white-label is active. |
| 70 | 73 | 'brandLogoUrl' => function_exists('yatra_get_brand_icon_url') ? yatra_get_brand_icon_url() : '', |
| @@ -153,9 +156,9 @@ | ||
| 153 | 156 | 'locale' => get_locale(), |
| 154 | 157 | 'currency' => \Yatra\Services\SettingsService::getCurrency(), |
| 155 | 158 | 'currencyPosition' => \Yatra\Services\SettingsService::getString('currency_position', 'left'), |
| 156 | 159 | 'currency_position' => \Yatra\Services\SettingsService::getString('currency_position', 'left'), |
| 157 | - 'decimalPlaces' => (int) \Yatra\Services\SettingsService::getString('currency_decimals', '2'), | |
| 160 | + 'decimalPlaces' => \Yatra\Services\SettingsService::getPriceDecimals(), | |
| 158 | 161 | 'thousandSeparator' => \Yatra\Services\SettingsService::getString('thousand_separator', ','), |
| 159 | 162 | 'decimalSeparator' => \Yatra\Services\SettingsService::getString('decimal_separator', '.'), |
| 160 | 163 | 'date_format' => \Yatra\Services\SettingsService::get('date_format', 'Y-m-d'), |
| 161 | 164 | 'time_format' => \Yatra\Services\SettingsService::get('time_format', 'H:i'), |
| @@ -405,9 +408,17 @@ | ||
| 405 | 408 | // Use built assets in production |
| 406 | 409 | $appJs = YATRA_PLUGIN_PATH . 'assets/admin/dist/js/app.js'; |
| 407 | 410 | |
| 408 | 411 | if (file_exists($appJs)) { |
| 409 | - $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); | |
| 410 | 421 | |
| 411 | 422 | $localized_data = $this->buildAdminLocalizedData(); |
| 412 | 423 | |
| 413 | 424 | // Enqueue our script with media library as dependency |
| @@ -428,11 +439,32 @@ | ||
| 428 | 439 | $jsVersion, |
| 429 | 440 | true |
| 430 | 441 | ); |
| 431 | 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 | + | |
| 432 | 456 | // Localize script data |
| 433 | 457 | wp_localize_script('yatra-admin', 'yatraAdmin', $localized_data); |
| 434 | 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 | + | |
| 435 | 467 | // Start fetching the ES module as early as possible (helps shorten white/splash time before React runs) |
| 436 | 468 | $app_js_url = YATRA_PLUGIN_URL . 'assets/admin/dist/js/app.js'; |
| 437 | 469 | add_action('admin_head', static function () use ($app_js_url, $jsVersion): void { |
| 438 | 470 | $href = esc_url(add_query_arg('ver', rawurlencode((string) $jsVersion), $app_js_url)); |
| @@ -449,20 +481,23 @@ | ||
| 449 | 481 | * @return bool |
| 450 | 482 | */ |
| 451 | 483 | private function isViteDevServerRunning(string $url): bool |
| 452 | 484 | { |
| 453 | - // 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. | |
| 454 | 488 | $assetUrl = $url . '/assets/admin/dist/js/app.js'; |
| 455 | - $ch = curl_init($assetUrl); | |
| 456 | - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |
| 457 | - curl_setopt($ch, CURLOPT_TIMEOUT, 2); // 2 second timeout | |
| 458 | - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); // 1 second connection timeout | |
| 459 | - curl_setopt($ch, CURLOPT_NOBODY, true); // HEAD request only | |
| 460 | - curl_exec($ch); | |
| 461 | - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| 462 | - curl_close($ch); | |
| 463 | - | |
| 464 | - 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; | |
| 465 | 500 | } |
| 466 | 501 | |
| 467 | 502 | /** |
| 468 | 503 | * Add inline script for media library compatibility |
| @@ -513,8 +548,119 @@ | ||
| 513 | 548 | * Enqueue setup wizard assets |
| 514 | 549 | * |
| 515 | 550 | * @return void |
| 516 | 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 | + | |
| 517 | 663 | public function enqueueSetupWizardAssets(): void |
| 518 | 664 | { |
| 519 | 665 | // Enqueue setup wizard CSS |
| 520 | 666 | $cssPath = YATRA_PLUGIN_PATH . 'assets/admin/css/setup-wizard.css'; |