PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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
← All changes | app/Helpers/FormatHelper.php +376 -2 3.0.8 → 3.0.16 View file →
@@ -304,8 +304,134 @@
304 304 return self::countryNames()[$code] ?? $code;
305 305 }
306 306
307 307 /**
308 + * Build a customer's postal address as display lines, for invoices and any
309 + * other document that has to show where the customer actually is.
310 + *
311 + * Two sources, in order of authority:
312 + * 1. The booking's own contact data — what the customer entered at the time
313 + * of purchase, which is what an invoice should reflect.
314 + * 2. The linked customer record, which carries the structured city / state /
315 + * postal code / country that the booking form does not always collect.
316 + *
317 + * @param object|array|null $booking Booking (or payment joined to one) carrying
318 + * contact_data / contact_country / customer_id.
319 + * @return string[] Non-empty address lines, ready to render one per line.
320 + */
321 + public static function customerAddressLines($booking): array
322 + {
323 + $get = static function ($source, string $key) {
324 + if (is_array($source)) {
325 + return $source[$key] ?? null;
326 + }
327 + if (is_object($source)) {
328 + return $source->$key ?? null;
329 + }
330 + return null;
331 + };
332 +
333 + $parts = ['address' => '', 'city' => '', 'state' => '', 'postal_code' => '', 'country' => ''];
334 +
335 + // 1. What the customer typed when booking.
336 + $contactData = $get($booking, 'contact_data');
337 + if (is_string($contactData) && $contactData !== '') {
338 + $contactData = json_decode($contactData, true);
339 + }
340 + if (is_array($contactData)) {
341 + foreach (array_keys($parts) as $key) {
342 + if (!empty($contactData[$key]) && is_scalar($contactData[$key])) {
343 + $parts[$key] = trim((string) $contactData[$key]);
344 + }
345 + }
346 + // The booking form stores the postcode under either name.
347 + if ($parts['postal_code'] === '' && !empty($contactData['zip'])) {
348 + $parts['postal_code'] = trim((string) $contactData['zip']);
349 + }
350 + }
351 +
352 + if ($parts['country'] === '') {
353 + $parts['country'] = trim((string) ($get($booking, 'contact_country') ?? ''));
354 + }
355 +
356 + // 2. Fill the gaps from the customer record.
357 + $customerId = (int) ($get($booking, 'customer_id') ?? 0);
358 + if ($customerId > 0 && in_array('', $parts, true)) {
359 + $customer = (new \Yatra\Repositories\CustomerRepository())->find($customerId);
360 +
361 + if ($customer) {
362 + foreach (array_keys($parts) as $key) {
363 + if ($parts[$key] === '' && !empty($customer->$key)) {
364 + $parts[$key] = trim((string) $customer->$key);
365 + }
366 + }
367 + }
368 + }
369 +
370 + // "City, State 12345" reads as one line; street and country get their own.
371 + $locality = trim(implode(', ', array_filter([$parts['city'], $parts['state']])));
372 + if ($parts['postal_code'] !== '') {
373 + $locality = trim($locality . ' ' . $parts['postal_code']);
374 + }
375 +
376 + $country = $parts['country'] !== '' ? self::getCountryName($parts['country']) : '';
377 +
378 + return array_values(array_filter([$parts['address'], $locality, $country], static function ($line) {
379 + return $line !== '';
380 + }));
381 + }
382 +
383 + /**
384 + * The operator's own business address, one entry per line.
385 + *
386 + * Settings hold the address in separate fields (street, city, state, postcode,
387 + * country) but documents only ever printed `company_address` — so everything
388 + * after the street silently vanished and the address looked truncated at its
389 + * final line. Composed the same way as {@see customerAddressLines()} so both
390 + * sides of a document read identically.
391 + *
392 + * @return list<string>
393 + */
394 + public static function companyAddressLines(): array
395 + {
396 + $get = static function (string $key): string {
397 + return trim((string) \Yatra\Services\SettingsService::get($key, ''));
398 + };
399 +
400 + $street = $get('company_address');
401 + $city = $get('company_city');
402 + $state = $get('company_state');
403 + $zip = $get('company_zip');
404 + $countryRaw = $get('company_country');
405 +
406 + // "City, State 12345" reads as one line; street and country get their own.
407 + $locality = trim(implode(', ', array_filter([$city, $state])));
408 + if ($zip !== '') {
409 + $locality = trim($locality . ' ' . $zip);
410 + }
411 +
412 + $country = $countryRaw !== '' ? self::getCountryName($countryRaw) : '';
413 +
414 + // The street field may itself carry line breaks (an operator pasting a full
415 + // address), so keep those as separate lines rather than one run-on entry.
416 + $lines = [];
417 + foreach (preg_split('/\R/', $street) ?: [] as $streetLine) {
418 + $streetLine = trim($streetLine);
419 + if ($streetLine !== '') {
420 + $lines[] = $streetLine;
421 + }
422 + }
423 +
424 + foreach ([$locality, $country] as $line) {
425 + if ($line !== '') {
426 + $lines[] = $line;
427 + }
428 + }
429 +
430 + return array_values($lines);
431 + }
432 +
433 + /**
308 434 * Canonical country list — single source of truth used by every
309 435 * country / nationality dropdown in both Free and Pro plugins.
310 436 *
311 437 * Returns the full ISO-3166-1 alpha-2 set (sovereign states +
@@ -340,8 +466,246 @@
340 466 return $countries;
341 467 }
342 468
343 469 /**
470 + * International dialing codes, keyed by ISO-3166-1 alpha-2 (digits only,
471 + * no leading "+"). Aligned to the {@see countryNames()} set. Multiple
472 + * countries legitimately share a code (all NANP countries use "1", RU/KZ
473 + * use "7", GB/GG/IM/JE use "44") — {@see dialingCodePriority()} resolves
474 + * which one an incoming "+code" auto-selects.
475 + *
476 + * @return array<string, string> [ISO code => dialing code]
477 + */
478 + private static function dialingCodes(): array
479 + {
480 + return [
481 + 'AF' => '93', 'AL' => '355', 'DZ' => '213', 'AD' => '376', 'AO' => '244', 'AI' => '1', 'AQ' => '672',
482 + 'AG' => '1', 'AR' => '54', 'AM' => '374', 'AW' => '297', 'AU' => '61', 'AT' => '43', 'AZ' => '994',
483 + 'BS' => '1', 'BH' => '973', 'BD' => '880', 'BB' => '1', 'BY' => '375', 'BE' => '32', 'BZ' => '501',
484 + 'BJ' => '229', 'BM' => '1', 'BT' => '975', 'BO' => '591', 'BQ' => '599', 'BA' => '387', 'BW' => '267',
485 + 'BR' => '55', 'IO' => '246', 'BN' => '673', 'BG' => '359', 'BF' => '226', 'BI' => '257', 'KH' => '855',
486 + 'CM' => '237', 'CA' => '1', 'CV' => '238', 'KY' => '1', 'CF' => '236', 'TD' => '235', 'CL' => '56',
487 + 'CN' => '86', 'CO' => '57', 'KM' => '269', 'CG' => '242', 'CD' => '243', 'CK' => '682', 'CR' => '506',
488 + 'CI' => '225', 'HR' => '385', 'CU' => '53', 'CW' => '599', 'CY' => '357', 'CZ' => '420', 'DK' => '45',
489 + 'DJ' => '253', 'DM' => '1', 'DO' => '1', 'EC' => '593', 'EG' => '20', 'SV' => '503', 'GQ' => '240',
490 + 'ER' => '291', 'EE' => '372', 'SZ' => '268', 'ET' => '251', 'FK' => '500', 'FO' => '298', 'FJ' => '679',
491 + 'FI' => '358', 'FR' => '33', 'GF' => '594', 'PF' => '689', 'GA' => '241', 'GM' => '220', 'GE' => '995',
492 + 'DE' => '49', 'GH' => '233', 'GI' => '350', 'GR' => '30', 'GL' => '299', 'GD' => '1', 'GP' => '590',
493 + 'GU' => '1', 'GT' => '502', 'GG' => '44', 'GN' => '224', 'GW' => '245', 'GY' => '592', 'HT' => '509',
494 + 'HN' => '504', 'HK' => '852', 'HU' => '36', 'IS' => '354', 'IN' => '91', 'ID' => '62', 'IR' => '98',
495 + 'IQ' => '964', 'IE' => '353', 'IM' => '44', 'IL' => '972', 'IT' => '39', 'JM' => '1', 'JP' => '81',
496 + 'JE' => '44', 'JO' => '962', 'KZ' => '7', 'KE' => '254', 'KI' => '686', 'KP' => '850', 'KR' => '82',
497 + 'KW' => '965', 'KG' => '996', 'LA' => '856', 'LV' => '371', 'LB' => '961', 'LS' => '266', 'LR' => '231',
498 + 'LY' => '218', 'LI' => '423', 'LT' => '370', 'LU' => '352', 'MO' => '853', 'MK' => '389', 'MG' => '261',
499 + 'MW' => '265', 'MY' => '60', 'MV' => '960', 'ML' => '223', 'MT' => '356', 'MH' => '692', 'MQ' => '596',
500 + 'MR' => '222', 'MU' => '230', 'YT' => '262', 'MX' => '52', 'FM' => '691', 'MD' => '373', 'MC' => '377',
501 + 'MN' => '976', 'ME' => '382', 'MS' => '1', 'MA' => '212', 'MZ' => '258', 'MM' => '95', 'NA' => '264',
502 + 'NR' => '674', 'NP' => '977', 'NL' => '31', 'NC' => '687', 'NZ' => '64', 'NI' => '505', 'NE' => '227',
503 + 'NG' => '234', 'NU' => '683', 'NF' => '672', 'MP' => '1', 'NO' => '47', 'OM' => '968', 'PK' => '92',
504 + 'PW' => '680', 'PS' => '970', 'PA' => '507', 'PG' => '675', 'PY' => '595', 'PE' => '51', 'PH' => '63',
505 + 'PN' => '64', 'PL' => '48', 'PT' => '351', 'PR' => '1', 'QA' => '974', 'RE' => '262', 'RO' => '40',
506 + 'RU' => '7', 'RW' => '250', 'BL' => '590', 'SH' => '290', 'KN' => '1', 'LC' => '1', 'MF' => '590',
507 + 'PM' => '508', 'VC' => '1', 'WS' => '685', 'SM' => '378', 'ST' => '239', 'SA' => '966', 'SN' => '221',
508 + 'RS' => '381', 'SC' => '248', 'SL' => '232', 'SG' => '65', 'SX' => '1', 'SK' => '421', 'SI' => '386',
509 + 'SB' => '677', 'SO' => '252', 'ZA' => '27', 'GS' => '500', 'SS' => '211', 'ES' => '34', 'LK' => '94',
510 + 'SD' => '249', 'SR' => '597', 'SJ' => '47', 'SE' => '46', 'CH' => '41', 'SY' => '963', 'TW' => '886',
511 + 'TJ' => '992', 'TZ' => '255', 'TH' => '66', 'TL' => '670', 'TG' => '228', 'TK' => '690', 'TO' => '676',
512 + 'TT' => '1', 'TN' => '216', 'TR' => '90', 'TM' => '993', 'TC' => '1', 'TV' => '688', 'UG' => '256',
513 + 'UA' => '380', 'AE' => '971', 'GB' => '44', 'US' => '1', 'UY' => '598', 'UZ' => '998', 'VU' => '678',
514 + 'VA' => '39', 'VE' => '58', 'VN' => '84', 'VG' => '1', 'VI' => '1', 'WF' => '681', 'EH' => '212',
515 + 'YE' => '967', 'ZM' => '260', 'ZW' => '263', 'XK' => '383', 'AS' => '1', 'AX' => '358',
516 + 'UM' => '1', 'CX' => '61', 'CC' => '61', 'TA' => '290',
517 + ];
518 + }
519 +
520 + /**
521 + * Preferred country for a dialing code that several countries share. When
522 + * an operator types a raw "+<code><number>" we must pick one country to
523 + * auto-select; this is the conventional primary (e.g. "1" => US, not one of
524 + * the ~20 other NANP countries). Codes not listed here fall back to the
525 + * first ISO that declares them.
526 + *
527 + * @return array<string, string> [dialing code => primary ISO]
528 + */
529 + private static function dialingCodePriority(): array
530 + {
531 + return [
532 + '1' => 'US', '7' => 'RU', '39' => 'IT', '44' => 'GB', '47' => 'NO', '61' => 'AU',
533 + '212' => 'MA', '262' => 'RE', '290' => 'SH', '358' => 'FI', '500' => 'FK',
534 + '590' => 'GP', '599' => 'CW', '64' => 'NZ', '672' => 'AQ',
535 + ];
536 + }
537 +
538 + /**
539 + * Preferred ISO per shared dialing code (dial => ISO), for the front-end
540 + * detector to resolve which country a "+code" auto-selects. Mirrors the
541 + * server-side {@see dialingCodePriority()}.
542 + *
543 + * @return array<string, string>
544 + */
545 + public static function getPhonePriority(): array
546 + {
547 + return self::dialingCodePriority();
548 + }
549 +
550 + /**
551 + * Dialing code (digits only, no "+") for one ISO code, or '' if unknown.
552 + */
553 + public static function getDialingCode(string $iso): string
554 + {
555 + $codes = self::dialingCodes();
556 + return $codes[strtoupper($iso)] ?? '';
557 + }
558 +
559 + /**
560 + * Country list for the phone-number field: ISO code, display name and
561 + * dialing code, sorted by display name. Filterable via
562 + * `yatra_phone_countries` (e.g. to pin popular countries to the top).
563 + *
564 + * @return array<int, array{iso:string, name:string, dial:string}>
565 + */
566 + public static function getPhoneCountries(): array
567 + {
568 + $names = self::getCountries(); // [iso => name], already sorted/filtered
569 + $dial = self::dialingCodes();
570 + $out = [];
571 + foreach ($names as $iso => $name) {
572 + $iso = strtoupper((string) $iso);
573 + if (!isset($dial[$iso]) || $dial[$iso] === '') {
574 + continue;
575 + }
576 + $out[] = ['iso' => $iso, 'name' => (string) $name, 'dial' => $dial[$iso]];
577 + }
578 + if (\function_exists('apply_filters')) {
579 + $filtered = apply_filters('yatra_phone_countries', $out);
580 + if (\is_array($filtered) && $filtered !== []) {
581 + return $filtered;
582 + }
583 + }
584 + return $out;
585 + }
586 +
587 + /**
588 + * Detect the country/dialing-code from a raw phone value. Only a value that
589 + * begins with "+" is treated as international; anything else returns null so
590 + * an existing bare national number is left untouched (backward compatible).
591 + *
592 + * Uses longest-prefix matching so "+9779806015400" → NP (977), not a shorter
593 + * false match, then applies {@see dialingCodePriority()} for shared codes.
594 + *
595 + * @return array{iso:string, dial:string, national:string}|null
596 + */
597 + public static function detectPhoneCountry(string $raw): ?array
598 + {
599 + $raw = trim($raw);
600 + if ($raw === '' || $raw[0] !== '+') {
601 + return null;
602 + }
603 + $digits = preg_replace('/\D+/', '', $raw);
604 + if ($digits === '' || $digits === null) {
605 + return null;
606 + }
607 + $codes = self::dialingCodes();
608 + $priority = self::dialingCodePriority();
609 + $maxLen = min(4, strlen($digits));
610 + for ($len = $maxLen; $len >= 1; $len--) {
611 + $prefix = substr($digits, 0, $len);
612 + $matches = array_keys($codes, $prefix, true);
613 + if ($matches === []) {
614 + continue;
615 + }
616 + $iso = $priority[$prefix] ?? (string) $matches[0];
617 + return [
618 + 'iso' => $iso,
619 + 'dial' => $prefix,
620 + 'national' => substr($digits, $len),
621 + ];
622 + }
623 + return null;
624 + }
625 +
626 + /**
627 + * Default country pre-selected in the phone field. Priority: the
628 + * `default_phone_country` setting → the site locale suffix (en_US → US,
629 + * ne_NP → NP) → "US". Filterable via `yatra_default_phone_country`.
630 + */
631 + public static function getDefaultPhoneCountry(): string
632 + {
633 + $codes = self::dialingCodes();
634 + $default = '';
635 +
636 + if (\class_exists(SettingsService::class)) {
637 + $setting = strtoupper((string) SettingsService::getString('default_phone_country', ''));
638 + if ($setting !== '' && isset($codes[$setting])) {
639 + $default = $setting;
640 + }
641 + }
642 + if ($default === '' && \function_exists('get_locale')) {
643 + if (preg_match('/_([A-Z]{2})$/', (string) get_locale(), $m) && isset($codes[$m[1]])) {
644 + $default = $m[1];
645 + }
646 + }
647 + if ($default === '') {
648 + $default = 'US';
649 + }
650 + if (\function_exists('apply_filters')) {
651 + $default = (string) apply_filters('yatra_default_phone_country', $default);
652 + }
653 + return isset($codes[$default]) ? $default : 'US';
654 + }
655 +
656 + /**
657 + * Combine a national number with a country (ISO) into the stored value.
658 + *
659 + * - Empty number → '' (nothing to store).
660 + * - Already-international ("+...") → returned unchanged (a pasted full number
661 + * or the no-JS fallback where the customer typed the "+code" themselves).
662 + * - National number + known ISO → "+<dial><digits>" (spaces/dashes stripped).
663 + * - No/unknown ISO → the number left as typed (legacy behavior, backward
664 + * compatible — nothing is invented).
665 + */
666 + public static function combineInternationalPhone(string $number, string $iso): string
667 + {
668 + $number = trim($number);
669 + if ($number === '') {
670 + return '';
671 + }
672 + if ($number[0] === '+') {
673 + return $number;
674 + }
675 + $iso = strtoupper(trim($iso));
676 + $dial = $iso !== '' ? self::getDialingCode($iso) : '';
677 + if ($dial === '') {
678 + return $number;
679 + }
680 + $digits = preg_replace('/\D+/', '', $number);
681 + if ($digits === '' || $digits === null) {
682 + return $number;
683 + }
684 + return '+' . $dial . $digits;
685 + }
686 +
687 + /**
688 + * Format a stored phone value for human display. A value saved with a
689 + * country code ("+9779806015400") is shown as "+977 9806015400"; a legacy
690 + * bare value is returned unchanged (never corrupted). Optionally accepts a
691 + * known ISO to disambiguate shared codes.
692 + */
693 + public static function formatPhoneForDisplay(string $stored, string $iso = ''): string
694 + {
695 + $stored = trim($stored);
696 + if ($stored === '') {
697 + return '';
698 + }
699 + $detected = self::detectPhoneCountry($stored);
700 + if ($detected === null) {
701 + return $stored; // legacy / national-only value — leave as-is
702 + }
703 + $national = $detected['national'] !== '' ? ' ' . $detected['national'] : '';
704 + return '+' . $detected['dial'] . $national;
705 + }
706 +
707 + /**
344 708 * Format phone number for display
345 709 *
346 710 * @param string $phone Phone number
347 711 * @return string Formatted phone
@@ -436,10 +800,20 @@
436 800 */
437 801 public static function formatRatingStars(float $rating, bool $showNumber = false): string
438 802 {
439 803 $rating = max(0, min(5, $rating));
440 - $fullStars = (int) floor($rating);
441 - $hasHalfStar = ($rating - $fullStars) >= 0.5;
804 +
805 + // Rounds to the nearest half star (see yatra_rating_star_parts), so this
806 + // renderer agrees with the confirmation page and reviews block.
807 + if (function_exists('yatra_rating_star_parts')) {
808 + $parts = yatra_rating_star_parts($rating);
809 + $fullStars = $parts['full'];
810 + $hasHalfStar = $parts['half'];
811 + } else {
812 + $halves = (int) round($rating * 2);
813 + $fullStars = intdiv($halves, 2);
814 + $hasHalfStar = ($halves % 2) === 1;
815 + }
442 816
443 817 $html = '<span class="yatra-rating-stars">';
444 818
445 819 for ($i = 1; $i <= 5; $i++) {