PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.11 All 82 releases
yatra / app / Helpers / FormatHelper.php

FormatHelper.php in Yatra – Travel Booking & Tour Operator Software 3.0.10, at app/Helpers/FormatHelper.php

923 lines 38.8 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\Helpers;
6
7 use Yatra\Services\SettingsService;
8
9 /**
10 * Format Helper
11 *
12 * Utility functions for formatting data.
13 * All methods are static for easy access throughout the application.
14 *
15 * Usage: FormatHelper::formatPrice(100, 'USD')
16 *
17 * @package Yatra\Helpers
18 */
19 class FormatHelper
20 {
21 // Currency symbols are now managed by CurrencyHelper
22
23 /**
24 * Country codes to names mapping
25 */
26 /**
27 * Country code => translated display name.
28 *
29 * Names are wrapped in __() at the definition so every consumer (booking
30 * form, admin, confirmation) shows localized names, and so all of them are
31 * extractable into the translation catalog. Cached per request.
32 */
33 private static ?array $countryNamesCache = null;
34
35 private static function countryNames(): array
36 {
37 if (self::$countryNamesCache !== null) {
38 return self::$countryNamesCache;
39 }
40
41 return self::$countryNamesCache = [
42
43 'AF' => __('Afghanistan', 'yatra'), 'AL' => __('Albania', 'yatra'), 'DZ' => __('Algeria', 'yatra'), 'AD' => __('Andorra', 'yatra'),
44 'AO' => __('Angola', 'yatra'), 'AG' => __('Antigua and Barbuda', 'yatra'), 'AR' => __('Argentina', 'yatra'), 'AM' => __('Armenia', 'yatra'),
45 'AU' => __('Australia', 'yatra'), 'AT' => __('Austria', 'yatra'), 'AZ' => __('Azerbaijan', 'yatra'), 'BS' => __('Bahamas', 'yatra'),
46 'BH' => __('Bahrain', 'yatra'), 'BD' => __('Bangladesh', 'yatra'), 'BB' => __('Barbados', 'yatra'), 'BY' => __('Belarus', 'yatra'),
47 'BE' => __('Belgium', 'yatra'), 'BZ' => __('Belize', 'yatra'), 'BJ' => __('Benin', 'yatra'), 'BT' => __('Bhutan', 'yatra'),
48 'BO' => __('Bolivia', 'yatra'), 'BA' => __('Bosnia and Herzegovina', 'yatra'), 'BW' => __('Botswana', 'yatra'), 'BR' => __('Brazil', 'yatra'),
49 'BN' => __('Brunei', 'yatra'), 'BG' => __('Bulgaria', 'yatra'), 'BF' => __('Burkina Faso', 'yatra'), 'BI' => __('Burundi', 'yatra'),
50 'KH' => __('Cambodia', 'yatra'), 'CM' => __('Cameroon', 'yatra'), 'CA' => __('Canada', 'yatra'), 'CV' => __('Cape Verde', 'yatra'),
51 'CF' => __('Central African Republic', 'yatra'), 'TD' => __('Chad', 'yatra'), 'CL' => __('Chile', 'yatra'), 'CN' => __('China', 'yatra'),
52 'CO' => __('Colombia', 'yatra'), 'KM' => __('Comoros', 'yatra'), 'CG' => __('Congo', 'yatra'), 'CD' => __('DR Congo', 'yatra'),
53 'CR' => __('Costa Rica', 'yatra'), 'CI' => __('Ivory Coast', 'yatra'), 'HR' => __('Croatia', 'yatra'), 'CU' => __('Cuba', 'yatra'),
54 'CY' => __('Cyprus', 'yatra'), 'CZ' => __('Czech Republic', 'yatra'), 'DK' => __('Denmark', 'yatra'), 'DJ' => __('Djibouti', 'yatra'),
55 'DM' => __('Dominica', 'yatra'), 'DO' => __('Dominican Republic', 'yatra'), 'EC' => __('Ecuador', 'yatra'), 'EG' => __('Egypt', 'yatra'),
56 'SV' => __('El Salvador', 'yatra'), 'GQ' => __('Equatorial Guinea', 'yatra'), 'ER' => __('Eritrea', 'yatra'), 'EE' => __('Estonia', 'yatra'),
57 'SZ' => __('Eswatini', 'yatra'), 'ET' => __('Ethiopia', 'yatra'), 'FJ' => __('Fiji', 'yatra'), 'FI' => __('Finland', 'yatra'),
58 'FR' => __('France', 'yatra'), 'GA' => __('Gabon', 'yatra'), 'GM' => __('Gambia', 'yatra'), 'GE' => __('Georgia', 'yatra'),
59 'DE' => __('Germany', 'yatra'), 'GH' => __('Ghana', 'yatra'), 'GR' => __('Greece', 'yatra'), 'GD' => __('Grenada', 'yatra'),
60 'GT' => __('Guatemala', 'yatra'), 'GN' => __('Guinea', 'yatra'), 'GW' => __('Guinea-Bissau', 'yatra'), 'GY' => __('Guyana', 'yatra'),
61 'HT' => __('Haiti', 'yatra'), 'HN' => __('Honduras', 'yatra'), 'HU' => __('Hungary', 'yatra'), 'IS' => __('Iceland', 'yatra'),
62 'IN' => __('India', 'yatra'), 'ID' => __('Indonesia', 'yatra'), 'IR' => __('Iran', 'yatra'), 'IQ' => __('Iraq', 'yatra'),
63 'IE' => __('Ireland', 'yatra'), 'IL' => __('Israel', 'yatra'), 'IT' => __('Italy', 'yatra'), 'JM' => __('Jamaica', 'yatra'),
64 'JP' => __('Japan', 'yatra'), 'JO' => __('Jordan', 'yatra'), 'KZ' => __('Kazakhstan', 'yatra'), 'KE' => __('Kenya', 'yatra'),
65 'KI' => __('Kiribati', 'yatra'), 'KP' => __('North Korea', 'yatra'), 'KR' => __('South Korea', 'yatra'), 'KW' => __('Kuwait', 'yatra'),
66 'KG' => __('Kyrgyzstan', 'yatra'), 'LA' => __('Laos', 'yatra'), 'LV' => __('Latvia', 'yatra'), 'LB' => __('Lebanon', 'yatra'),
67 'LS' => __('Lesotho', 'yatra'), 'LR' => __('Liberia', 'yatra'), 'LY' => __('Libya', 'yatra'), 'LI' => __('Liechtenstein', 'yatra'),
68 'LT' => __('Lithuania', 'yatra'), 'LU' => __('Luxembourg', 'yatra'), 'MG' => __('Madagascar', 'yatra'), 'MW' => __('Malawi', 'yatra'),
69 'MY' => __('Malaysia', 'yatra'), 'MV' => __('Maldives', 'yatra'), 'ML' => __('Mali', 'yatra'), 'MT' => __('Malta', 'yatra'),
70 'MH' => __('Marshall Islands', 'yatra'), 'MR' => __('Mauritania', 'yatra'), 'MU' => __('Mauritius', 'yatra'), 'MX' => __('Mexico', 'yatra'),
71 'FM' => __('Micronesia', 'yatra'), 'MD' => __('Moldova', 'yatra'), 'MC' => __('Monaco', 'yatra'), 'MN' => __('Mongolia', 'yatra'),
72 'ME' => __('Montenegro', 'yatra'), 'MA' => __('Morocco', 'yatra'), 'MZ' => __('Mozambique', 'yatra'), 'MM' => __('Myanmar', 'yatra'),
73 'NA' => __('Namibia', 'yatra'), 'NR' => __('Nauru', 'yatra'), 'NP' => __('Nepal', 'yatra'), 'NL' => __('Netherlands', 'yatra'),
74 'NZ' => __('New Zealand', 'yatra'), 'NI' => __('Nicaragua', 'yatra'), 'NE' => __('Niger', 'yatra'), 'NG' => __('Nigeria', 'yatra'),
75 'MK' => __('North Macedonia', 'yatra'), 'NO' => __('Norway', 'yatra'), 'OM' => __('Oman', 'yatra'), 'PK' => __('Pakistan', 'yatra'),
76 'PW' => __('Palau', 'yatra'), 'PS' => __('Palestine', 'yatra'), 'PA' => __('Panama', 'yatra'), 'PG' => __('Papua New Guinea', 'yatra'),
77 'PY' => __('Paraguay', 'yatra'), 'PE' => __('Peru', 'yatra'), 'PH' => __('Philippines', 'yatra'), 'PL' => __('Poland', 'yatra'),
78 'PT' => __('Portugal', 'yatra'), 'QA' => __('Qatar', 'yatra'), 'RO' => __('Romania', 'yatra'), 'RU' => __('Russia', 'yatra'),
79 'RW' => __('Rwanda', 'yatra'), 'KN' => __('Saint Kitts and Nevis', 'yatra'), 'LC' => __('Saint Lucia', 'yatra'),
80 'VC' => __('Saint Vincent and the Grenadines', 'yatra'), 'WS' => __('Samoa', 'yatra'), 'SM' => __('San Marino', 'yatra'),
81 'ST' => __('Sao Tome and Principe', 'yatra'), 'SA' => __('Saudi Arabia', 'yatra'), 'SN' => __('Senegal', 'yatra'),
82 'RS' => __('Serbia', 'yatra'), 'SC' => __('Seychelles', 'yatra'), 'SL' => __('Sierra Leone', 'yatra'), 'SG' => __('Singapore', 'yatra'),
83 'SK' => __('Slovakia', 'yatra'), 'SI' => __('Slovenia', 'yatra'), 'SB' => __('Solomon Islands', 'yatra'), 'SO' => __('Somalia', 'yatra'),
84 'ZA' => __('South Africa', 'yatra'), 'SS' => __('South Sudan', 'yatra'), 'ES' => __('Spain', 'yatra'), 'LK' => __('Sri Lanka', 'yatra'),
85 'SD' => __('Sudan', 'yatra'), 'SR' => __('Suriname', 'yatra'), 'SE' => __('Sweden', 'yatra'), 'CH' => __('Switzerland', 'yatra'),
86 'SY' => __('Syria', 'yatra'), 'TW' => __('Taiwan', 'yatra'), 'TJ' => __('Tajikistan', 'yatra'), 'TZ' => __('Tanzania', 'yatra'),
87 'TH' => __('Thailand', 'yatra'), 'TL' => __('Timor-Leste', 'yatra'), 'TG' => __('Togo', 'yatra'), 'TO' => __('Tonga', 'yatra'),
88 'TT' => __('Trinidad and Tobago', 'yatra'), 'TN' => __('Tunisia', 'yatra'), 'TR' => __('Turkey', 'yatra'), 'TM' => __('Turkmenistan', 'yatra'),
89 'TV' => __('Tuvalu', 'yatra'), 'UG' => __('Uganda', 'yatra'), 'UA' => __('Ukraine', 'yatra'), 'AE' => __('United Arab Emirates', 'yatra'),
90 'GB' => __('United Kingdom', 'yatra'), 'US' => __('United States', 'yatra'), 'UY' => __('Uruguay', 'yatra'), 'UZ' => __('Uzbekistan', 'yatra'),
91 'VU' => __('Vanuatu', 'yatra'), 'VA' => __('Vatican City', 'yatra'), 'VE' => __('Venezuela', 'yatra'), 'VN' => __('Vietnam', 'yatra'),
92 'YE' => __('Yemen', 'yatra'), 'ZM' => __('Zambia', 'yatra'), 'ZW' => __('Zimbabwe', 'yatra'),
93 // ISO-3166-1 territories and special regions added so customers
94 // from common tourism markets (Hong Kong, Puerto Rico, Greenland,
95 // Gibraltar, Faroe Islands, etc.) can select their location.
96 'HK' => __('Hong Kong', 'yatra'), 'MO' => __('Macao', 'yatra'), 'PR' => __('Puerto Rico', 'yatra'),
97 'GI' => __('Gibraltar', 'yatra'), 'GL' => __('Greenland', 'yatra'), 'FO' => __('Faroe Islands', 'yatra'),
98 'GG' => __('Guernsey', 'yatra'), 'IM' => __('Isle of Man', 'yatra'), 'JE' => __('Jersey', 'yatra'),
99 'AX' => __('Aland Islands', 'yatra'), 'SJ' => __('Svalbard and Jan Mayen', 'yatra'),
100 'BM' => __('Bermuda', 'yatra'), 'KY' => __('Cayman Islands', 'yatra'), 'AI' => __('Anguilla', 'yatra'),
101 'AW' => __('Aruba', 'yatra'), 'CW' => __('Curacao', 'yatra'), 'SX' => __('Sint Maarten', 'yatra'),
102 'BQ' => __('Bonaire, Sint Eustatius and Saba', 'yatra'),
103 'MS' => __('Montserrat', 'yatra'), 'VG' => __('British Virgin Islands', 'yatra'),
104 'TC' => __('Turks and Caicos Islands', 'yatra'),
105 'BL' => __('Saint Barthelemy', 'yatra'), 'MF' => __('Saint Martin (French)', 'yatra'),
106 'PM' => __('Saint Pierre and Miquelon', 'yatra'),
107 'SH' => __('Saint Helena, Ascension and Tristan da Cunha', 'yatra'),
108 'FK' => __('Falkland Islands', 'yatra'),
109 'GS' => __('South Georgia and the South Sandwich Islands', 'yatra'),
110 'PF' => __('French Polynesia', 'yatra'), 'NC' => __('New Caledonia', 'yatra'),
111 'WF' => __('Wallis and Futuna', 'yatra'), 'CK' => __('Cook Islands', 'yatra'),
112 'NU' => __('Niue', 'yatra'), 'TK' => __('Tokelau', 'yatra'),
113 'GP' => __('Guadeloupe', 'yatra'), 'MQ' => __('Martinique', 'yatra'),
114 'RE' => __('Reunion', 'yatra'), 'YT' => __('Mayotte', 'yatra'),
115 'AS' => __('American Samoa', 'yatra'), 'GU' => __('Guam', 'yatra'),
116 'MP' => __('Northern Mariana Islands', 'yatra'), 'VI' => __('U.S. Virgin Islands', 'yatra'),
117 'UM' => __('U.S. Minor Outlying Islands', 'yatra'),
118 'TF' => __('French Southern Territories', 'yatra'),
119 'IO' => __('British Indian Ocean Territory', 'yatra'), 'BV' => __('Bouvet Island', 'yatra'),
120 'HM' => __('Heard Island and McDonald Islands', 'yatra'),
121 'AQ' => __('Antarctica', 'yatra'), 'PN' => __('Pitcairn', 'yatra'),
122 'EH' => __('Western Sahara', 'yatra'), 'XK' => __('Kosovo', 'yatra'),
123 ];
124 }
125
126 /**
127 * Format price with currency
128 *
129 * @param float $amount Amount to format
130 * @param string $currency Currency code (default: USD)
131 * @param bool $showCode Show currency code alongside symbol
132 * @return string Formatted price
133 */
134 public static function formatPrice(float $amount, string $currency = 'USD', bool $showCode = false): string
135 {
136 if (function_exists('yatra_format_price')) {
137 $main = yatra_format_price($amount, $currency, false);
138 } else {
139 $main = CurrencyHelper::format($amount, $currency, false);
140 }
141
142 if ($showCode) {
143 return $main . ' ' . strtoupper($currency);
144 }
145
146 return $main;
147 }
148
149 /**
150 * Get currency symbol
151 *
152 * @param string $currency Currency code
153 * @return string Currency symbol
154 */
155 public static function getCurrencySymbol(string $currency): string
156 {
157 return CurrencyHelper::getSymbol($currency);
158 }
159
160 /**
161 * Format date for display
162 *
163 * @param string $date Date string
164 * @param string $format PHP date format (default: from WordPress settings)
165 * @return string Formatted date
166 */
167 public static function formatDate(string $date, string $format = ''): string
168 {
169 if (empty($date)) {
170 return '';
171 }
172
173 $format = $format ?: SettingsService::getString('date_format', (string) get_option('date_format', 'F j, Y'));
174
175 $timestamp = strtotime($date);
176 if ($timestamp === false) {
177 return $date;
178 }
179
180 return self::formatTimestampWithTz($timestamp, $format);
181 }
182
183 /**
184 * Format datetime for display
185 *
186 * @param string $datetime Datetime string
187 * @param string $format PHP date format
188 * @return string Formatted datetime
189 */
190 public static function formatDateTime(string $datetime, string $format = ''): string
191 {
192 if (empty($datetime)) {
193 return '';
194 }
195
196 $dateFormat = SettingsService::getString('date_format', (string) get_option('date_format', 'F j, Y'));
197 $timeFormat = SettingsService::getString('time_format', (string) get_option('time_format', 'g:i a'));
198 $format = $format ?: $dateFormat . ' ' . $timeFormat;
199
200 $timestamp = strtotime($datetime);
201 if ($timestamp === false) {
202 return $datetime;
203 }
204
205 return self::formatTimestampWithTz($timestamp, $format);
206 }
207
208 /**
209 * Format time for display
210 *
211 * @param string $time Time string (e.g., "14:30")
212 * @return string Formatted time (e.g., "2:30 PM")
213 */
214 public static function formatTime(string $time): string
215 {
216 return self::formatTimeForDisplay($time);
217 }
218
219 /**
220 * Format time for display using plugin settings (fallback to WordPress settings)
221 *
222 * @param string $time Time string (e.g., "14:30")
223 * @return string Formatted time (e.g., "2:30 PM")
224 */
225 public static function formatTimeForDisplay(string $time): string
226 {
227 if (empty($time)) {
228 return '';
229 }
230
231 $timestamp = strtotime($time);
232 if ($timestamp === false) {
233 return $time;
234 }
235
236 $format = SettingsService::getString('time_format', (string) get_option('time_format', 'g:i a'));
237 return self::formatTimestampWithTz($timestamp, $format);
238 }
239
240 /**
241 * Format a timestamp using Yatra timezone when set.
242 *
243 * @param int $timestamp Unix timestamp
244 * @param string $format PHP date format
245 * @return string
246 */
247 private static function formatTimestampWithTz(int $timestamp, string $format): string
248 {
249 $tz = trim((string) SettingsService::getString('timezone', ''));
250 if ($tz === '') {
251 // WP site timezone fallback
252 return function_exists('wp_date')
253 ? wp_date($format, $timestamp)
254 : date_i18n($format, $timestamp);
255 }
256
257 try {
258 $dtz = new \DateTimeZone($tz);
259 } catch (\Exception $e) {
260 return function_exists('wp_date')
261 ? wp_date($format, $timestamp)
262 : date_i18n($format, $timestamp);
263 }
264
265 if (function_exists('wp_date')) {
266 return wp_date($format, $timestamp, $dtz);
267 }
268
269 // Older WP fallback: shift timestamp into requested timezone via DateTime.
270 $d = new \DateTime('@' . $timestamp);
271 $d->setTimezone($dtz);
272 return $d->format($format);
273 }
274
275 /**
276 * Get relative time (e.g., "2 hours ago")
277 *
278 * @param string $datetime Datetime string
279 * @return string Relative time
280 */
281 public static function timeAgo(string $datetime): string
282 {
283 if (empty($datetime)) {
284 return '';
285 }
286
287 $timestamp = strtotime($datetime);
288 if ($timestamp === false) {
289 return $datetime;
290 }
291
292 return human_time_diff($timestamp, current_time('timestamp')) . ' ' . __('ago', 'yatra');
293 }
294
295 /**
296 * Get country name from code
297 *
298 * @param string $code Country code (e.g., "US")
299 * @return string Country name
300 */
301 public static function getCountryName(string $code): string
302 {
303 $code = strtoupper($code);
304 return self::countryNames()[$code] ?? $code;
305 }
306
307 /**
308 * Canonical country list — single source of truth used by every
309 * country / nationality dropdown in both Free and Pro plugins.
310 *
311 * Returns the full ISO-3166-1 alpha-2 set (sovereign states +
312 * dependent territories + commonly-accepted regions). Sorted
313 * alphabetically by name so the rendered dropdown is browseable
314 * without operators having to scan a code-ordered list.
315 *
316 * Filterable via `yatra_countries_list` for operators that want
317 * to:
318 * - Prepend "popular" entries (US, GB, IN, etc.) above an
319 * `---` separator for faster picking
320 * - Remove territories that don't apply to their market
321 * - Rename a region (e.g. business-language preferences)
322 *
323 * @return array<string, string> [ISO-3166 code => display name]
324 */
325 public static function getCountries(): array
326 {
327 $countries = self::countryNames();
328
329 // Stable alphabetical sort by display name. Operators expect
330 // "Argentina" before "Australia" before "Austria" — code-order
331 // (AR/AU/AT) is computer-friendly but not human-friendly.
332 asort($countries, SORT_STRING | SORT_FLAG_CASE);
333
334 if (\function_exists('apply_filters')) {
335 $filtered = apply_filters('yatra_countries_list', $countries);
336 if (\is_array($filtered) && $filtered !== []) {
337 return $filtered;
338 }
339 }
340 return $countries;
341 }
342
343 /**
344 * International dialing codes, keyed by ISO-3166-1 alpha-2 (digits only,
345 * no leading "+"). Aligned to the {@see countryNames()} set. Multiple
346 * countries legitimately share a code (all NANP countries use "1", RU/KZ
347 * use "7", GB/GG/IM/JE use "44") — {@see dialingCodePriority()} resolves
348 * which one an incoming "+code" auto-selects.
349 *
350 * @return array<string, string> [ISO code => dialing code]
351 */
352 private static function dialingCodes(): array
353 {
354 return [
355 'AF' => '93', 'AL' => '355', 'DZ' => '213', 'AD' => '376', 'AO' => '244', 'AI' => '1', 'AQ' => '672',
356 'AG' => '1', 'AR' => '54', 'AM' => '374', 'AW' => '297', 'AU' => '61', 'AT' => '43', 'AZ' => '994',
357 'BS' => '1', 'BH' => '973', 'BD' => '880', 'BB' => '1', 'BY' => '375', 'BE' => '32', 'BZ' => '501',
358 'BJ' => '229', 'BM' => '1', 'BT' => '975', 'BO' => '591', 'BQ' => '599', 'BA' => '387', 'BW' => '267',
359 'BR' => '55', 'IO' => '246', 'BN' => '673', 'BG' => '359', 'BF' => '226', 'BI' => '257', 'KH' => '855',
360 'CM' => '237', 'CA' => '1', 'CV' => '238', 'KY' => '1', 'CF' => '236', 'TD' => '235', 'CL' => '56',
361 'CN' => '86', 'CO' => '57', 'KM' => '269', 'CG' => '242', 'CD' => '243', 'CK' => '682', 'CR' => '506',
362 'CI' => '225', 'HR' => '385', 'CU' => '53', 'CW' => '599', 'CY' => '357', 'CZ' => '420', 'DK' => '45',
363 'DJ' => '253', 'DM' => '1', 'DO' => '1', 'EC' => '593', 'EG' => '20', 'SV' => '503', 'GQ' => '240',
364 'ER' => '291', 'EE' => '372', 'SZ' => '268', 'ET' => '251', 'FK' => '500', 'FO' => '298', 'FJ' => '679',
365 'FI' => '358', 'FR' => '33', 'GF' => '594', 'PF' => '689', 'GA' => '241', 'GM' => '220', 'GE' => '995',
366 'DE' => '49', 'GH' => '233', 'GI' => '350', 'GR' => '30', 'GL' => '299', 'GD' => '1', 'GP' => '590',
367 'GU' => '1', 'GT' => '502', 'GG' => '44', 'GN' => '224', 'GW' => '245', 'GY' => '592', 'HT' => '509',
368 'HN' => '504', 'HK' => '852', 'HU' => '36', 'IS' => '354', 'IN' => '91', 'ID' => '62', 'IR' => '98',
369 'IQ' => '964', 'IE' => '353', 'IM' => '44', 'IL' => '972', 'IT' => '39', 'JM' => '1', 'JP' => '81',
370 'JE' => '44', 'JO' => '962', 'KZ' => '7', 'KE' => '254', 'KI' => '686', 'KP' => '850', 'KR' => '82',
371 'KW' => '965', 'KG' => '996', 'LA' => '856', 'LV' => '371', 'LB' => '961', 'LS' => '266', 'LR' => '231',
372 'LY' => '218', 'LI' => '423', 'LT' => '370', 'LU' => '352', 'MO' => '853', 'MK' => '389', 'MG' => '261',
373 'MW' => '265', 'MY' => '60', 'MV' => '960', 'ML' => '223', 'MT' => '356', 'MH' => '692', 'MQ' => '596',
374 'MR' => '222', 'MU' => '230', 'YT' => '262', 'MX' => '52', 'FM' => '691', 'MD' => '373', 'MC' => '377',
375 'MN' => '976', 'ME' => '382', 'MS' => '1', 'MA' => '212', 'MZ' => '258', 'MM' => '95', 'NA' => '264',
376 'NR' => '674', 'NP' => '977', 'NL' => '31', 'NC' => '687', 'NZ' => '64', 'NI' => '505', 'NE' => '227',
377 'NG' => '234', 'NU' => '683', 'NF' => '672', 'MP' => '1', 'NO' => '47', 'OM' => '968', 'PK' => '92',
378 'PW' => '680', 'PS' => '970', 'PA' => '507', 'PG' => '675', 'PY' => '595', 'PE' => '51', 'PH' => '63',
379 'PN' => '64', 'PL' => '48', 'PT' => '351', 'PR' => '1', 'QA' => '974', 'RE' => '262', 'RO' => '40',
380 'RU' => '7', 'RW' => '250', 'BL' => '590', 'SH' => '290', 'KN' => '1', 'LC' => '1', 'MF' => '590',
381 'PM' => '508', 'VC' => '1', 'WS' => '685', 'SM' => '378', 'ST' => '239', 'SA' => '966', 'SN' => '221',
382 'RS' => '381', 'SC' => '248', 'SL' => '232', 'SG' => '65', 'SX' => '1', 'SK' => '421', 'SI' => '386',
383 'SB' => '677', 'SO' => '252', 'ZA' => '27', 'GS' => '500', 'SS' => '211', 'ES' => '34', 'LK' => '94',
384 'SD' => '249', 'SR' => '597', 'SJ' => '47', 'SE' => '46', 'CH' => '41', 'SY' => '963', 'TW' => '886',
385 'TJ' => '992', 'TZ' => '255', 'TH' => '66', 'TL' => '670', 'TG' => '228', 'TK' => '690', 'TO' => '676',
386 'TT' => '1', 'TN' => '216', 'TR' => '90', 'TM' => '993', 'TC' => '1', 'TV' => '688', 'UG' => '256',
387 'UA' => '380', 'AE' => '971', 'GB' => '44', 'US' => '1', 'UY' => '598', 'UZ' => '998', 'VU' => '678',
388 'VA' => '39', 'VE' => '58', 'VN' => '84', 'VG' => '1', 'VI' => '1', 'WF' => '681', 'EH' => '212',
389 'YE' => '967', 'ZM' => '260', 'ZW' => '263', 'XK' => '383', 'AS' => '1', 'AX' => '358',
390 'UM' => '1', 'CX' => '61', 'CC' => '61', 'TA' => '290',
391 ];
392 }
393
394 /**
395 * Preferred country for a dialing code that several countries share. When
396 * an operator types a raw "+<code><number>" we must pick one country to
397 * auto-select; this is the conventional primary (e.g. "1" => US, not one of
398 * the ~20 other NANP countries). Codes not listed here fall back to the
399 * first ISO that declares them.
400 *
401 * @return array<string, string> [dialing code => primary ISO]
402 */
403 private static function dialingCodePriority(): array
404 {
405 return [
406 '1' => 'US', '7' => 'RU', '39' => 'IT', '44' => 'GB', '47' => 'NO', '61' => 'AU',
407 '212' => 'MA', '262' => 'RE', '290' => 'SH', '358' => 'FI', '500' => 'FK',
408 '590' => 'GP', '599' => 'CW', '64' => 'NZ', '672' => 'AQ',
409 ];
410 }
411
412 /**
413 * Preferred ISO per shared dialing code (dial => ISO), for the front-end
414 * detector to resolve which country a "+code" auto-selects. Mirrors the
415 * server-side {@see dialingCodePriority()}.
416 *
417 * @return array<string, string>
418 */
419 public static function getPhonePriority(): array
420 {
421 return self::dialingCodePriority();
422 }
423
424 /**
425 * Dialing code (digits only, no "+") for one ISO code, or '' if unknown.
426 */
427 public static function getDialingCode(string $iso): string
428 {
429 $codes = self::dialingCodes();
430 return $codes[strtoupper($iso)] ?? '';
431 }
432
433 /**
434 * Country list for the phone-number field: ISO code, display name and
435 * dialing code, sorted by display name. Filterable via
436 * `yatra_phone_countries` (e.g. to pin popular countries to the top).
437 *
438 * @return array<int, array{iso:string, name:string, dial:string}>
439 */
440 public static function getPhoneCountries(): array
441 {
442 $names = self::getCountries(); // [iso => name], already sorted/filtered
443 $dial = self::dialingCodes();
444 $out = [];
445 foreach ($names as $iso => $name) {
446 $iso = strtoupper((string) $iso);
447 if (!isset($dial[$iso]) || $dial[$iso] === '') {
448 continue;
449 }
450 $out[] = ['iso' => $iso, 'name' => (string) $name, 'dial' => $dial[$iso]];
451 }
452 if (\function_exists('apply_filters')) {
453 $filtered = apply_filters('yatra_phone_countries', $out);
454 if (\is_array($filtered) && $filtered !== []) {
455 return $filtered;
456 }
457 }
458 return $out;
459 }
460
461 /**
462 * Detect the country/dialing-code from a raw phone value. Only a value that
463 * begins with "+" is treated as international; anything else returns null so
464 * an existing bare national number is left untouched (backward compatible).
465 *
466 * Uses longest-prefix matching so "+9779806015400" → NP (977), not a shorter
467 * false match, then applies {@see dialingCodePriority()} for shared codes.
468 *
469 * @return array{iso:string, dial:string, national:string}|null
470 */
471 public static function detectPhoneCountry(string $raw): ?array
472 {
473 $raw = trim($raw);
474 if ($raw === '' || $raw[0] !== '+') {
475 return null;
476 }
477 $digits = preg_replace('/\D+/', '', $raw);
478 if ($digits === '' || $digits === null) {
479 return null;
480 }
481 $codes = self::dialingCodes();
482 $priority = self::dialingCodePriority();
483 $maxLen = min(4, strlen($digits));
484 for ($len = $maxLen; $len >= 1; $len--) {
485 $prefix = substr($digits, 0, $len);
486 $matches = array_keys($codes, $prefix, true);
487 if ($matches === []) {
488 continue;
489 }
490 $iso = $priority[$prefix] ?? (string) $matches[0];
491 return [
492 'iso' => $iso,
493 'dial' => $prefix,
494 'national' => substr($digits, $len),
495 ];
496 }
497 return null;
498 }
499
500 /**
501 * Default country pre-selected in the phone field. Priority: the
502 * `default_phone_country` setting → the site locale suffix (en_US → US,
503 * ne_NP → NP) → "US". Filterable via `yatra_default_phone_country`.
504 */
505 public static function getDefaultPhoneCountry(): string
506 {
507 $codes = self::dialingCodes();
508 $default = '';
509
510 if (\class_exists(SettingsService::class)) {
511 $setting = strtoupper((string) SettingsService::getString('default_phone_country', ''));
512 if ($setting !== '' && isset($codes[$setting])) {
513 $default = $setting;
514 }
515 }
516 if ($default === '' && \function_exists('get_locale')) {
517 if (preg_match('/_([A-Z]{2})$/', (string) get_locale(), $m) && isset($codes[$m[1]])) {
518 $default = $m[1];
519 }
520 }
521 if ($default === '') {
522 $default = 'US';
523 }
524 if (\function_exists('apply_filters')) {
525 $default = (string) apply_filters('yatra_default_phone_country', $default);
526 }
527 return isset($codes[$default]) ? $default : 'US';
528 }
529
530 /**
531 * Combine a national number with a country (ISO) into the stored value.
532 *
533 * - Empty number → '' (nothing to store).
534 * - Already-international ("+...") → returned unchanged (a pasted full number
535 * or the no-JS fallback where the customer typed the "+code" themselves).
536 * - National number + known ISO → "+<dial><digits>" (spaces/dashes stripped).
537 * - No/unknown ISO → the number left as typed (legacy behavior, backward
538 * compatible — nothing is invented).
539 */
540 public static function combineInternationalPhone(string $number, string $iso): string
541 {
542 $number = trim($number);
543 if ($number === '') {
544 return '';
545 }
546 if ($number[0] === '+') {
547 return $number;
548 }
549 $iso = strtoupper(trim($iso));
550 $dial = $iso !== '' ? self::getDialingCode($iso) : '';
551 if ($dial === '') {
552 return $number;
553 }
554 $digits = preg_replace('/\D+/', '', $number);
555 if ($digits === '' || $digits === null) {
556 return $number;
557 }
558 return '+' . $dial . $digits;
559 }
560
561 /**
562 * Format a stored phone value for human display. A value saved with a
563 * country code ("+9779806015400") is shown as "+977 9806015400"; a legacy
564 * bare value is returned unchanged (never corrupted). Optionally accepts a
565 * known ISO to disambiguate shared codes.
566 */
567 public static function formatPhoneForDisplay(string $stored, string $iso = ''): string
568 {
569 $stored = trim($stored);
570 if ($stored === '') {
571 return '';
572 }
573 $detected = self::detectPhoneCountry($stored);
574 if ($detected === null) {
575 return $stored; // legacy / national-only value — leave as-is
576 }
577 $national = $detected['national'] !== '' ? ' ' . $detected['national'] : '';
578 return '+' . $detected['dial'] . $national;
579 }
580
581 /**
582 * Format phone number for display
583 *
584 * @param string $phone Phone number
585 * @return string Formatted phone
586 */
587 public static function formatPhone(string $phone): string
588 {
589 // Remove non-numeric characters except + and spaces
590 $phone = preg_replace('/[^\d\+\s\-\(\)]/', '', $phone);
591 return trim($phone);
592 }
593
594 /**
595 * Format duration (days/nights)
596 *
597 * @param int $days Number of days
598 * @param int|null $nights Number of nights (optional)
599 * @return string Formatted duration
600 */
601 public static function formatDuration(int $days, ?int $nights = null): string
602 {
603 if ($nights !== null) {
604 return sprintf(
605 _n('%d Day', '%d Days', $days, 'yatra'),
606 $days
607 ) . ' / ' . sprintf(
608 _n('%d Night', '%d Nights', $nights, 'yatra'),
609 $nights
610 );
611 }
612
613 return sprintf(_n('%d Day', '%d Days', $days, 'yatra'), $days);
614 }
615
616 /**
617 * Format file size
618 *
619 * @param int $bytes File size in bytes
620 * @return string Formatted size
621 */
622 public static function formatFileSize(int $bytes): string
623 {
624 $units = ['B', 'KB', 'MB', 'GB', 'TB'];
625 $unitIndex = 0;
626
627 while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
628 $bytes /= 1024;
629 $unitIndex++;
630 }
631
632 return round($bytes, 2) . ' ' . $units[$unitIndex];
633 }
634
635 /**
636 * Truncate text with ellipsis
637 *
638 * @param string $text Text to truncate
639 * @param int $length Maximum length
640 * @param string $suffix Suffix to add (default: ...)
641 * @return string Truncated text
642 */
643 public static function truncate(string $text, int $length = 100, string $suffix = '...'): string
644 {
645 if (mb_strlen($text) <= $length) {
646 return $text;
647 }
648
649 return mb_substr($text, 0, $length - mb_strlen($suffix)) . $suffix;
650 }
651
652 /**
653 * Generate excerpt from HTML content
654 *
655 * @param string $html HTML content
656 * @param int $length Maximum length
657 * @return string Plain text excerpt
658 */
659 public static function excerpt(string $html, int $length = 150): string
660 {
661 $text = wp_strip_all_tags($html);
662 $text = preg_replace('/\s+/', ' ', $text);
663 $text = trim($text);
664
665 return self::truncate($text, $length);
666 }
667
668 /**
669 * Format rating as stars HTML
670 *
671 * @param float $rating Rating (0-5)
672 * @param bool $showNumber Show rating number
673 * @return string HTML stars
674 */
675 public static function formatRatingStars(float $rating, bool $showNumber = false): string
676 {
677 $rating = max(0, min(5, $rating));
678 $fullStars = (int) floor($rating);
679 $hasHalfStar = ($rating - $fullStars) >= 0.5;
680
681 $html = '<span class="yatra-rating-stars">';
682
683 for ($i = 1; $i <= 5; $i++) {
684 if ($i <= $fullStars) {
685 $html .= '<span class="yatra-star filled">�
686 </span>';
687 } elseif ($i === $fullStars + 1 && $hasHalfStar) {
688 $html .= '<span class="yatra-star half">�
689 </span>';
690 } else {
691 $html .= '<span class="yatra-star">�
692 </span>';
693 }
694 }
695
696 $html .= '</span>';
697
698 if ($showNumber) {
699 $html .= '<span class="yatra-rating-number">' . number_format($rating, 1) . '</span>';
700 }
701
702 return $html;
703 }
704
705 /**
706 * Format booking status as badge
707 *
708 * @param string $status Booking status
709 * @return string HTML badge
710 */
711 public static function formatStatusBadge(string $status): string
712 {
713 $statusClasses = [
714 'pending' => 'yatra-badge yatra-badge-warning',
715 'confirmed' => 'yatra-badge yatra-badge-success',
716 'processing' => 'yatra-badge yatra-badge-info',
717 'completed' => 'yatra-badge yatra-badge-success',
718 'cancelled' => 'yatra-badge yatra-badge-danger',
719 'refunded' => 'yatra-badge yatra-badge-secondary',
720 'failed' => 'yatra-badge yatra-badge-danger',
721 'on_hold' => 'yatra-badge yatra-badge-warning',
722 ];
723
724 $class = $statusClasses[$status] ?? 'yatra-badge';
725 $label = ucfirst(str_replace('_', ' ', $status));
726
727 return '<span class="' . esc_attr($class) . '">' . esc_html($label) . '</span>';
728 }
729
730 /**
731 * Sanitize and format slug
732 *
733 * @param string $text Text to slugify
734 * @return string URL-safe slug
735 */
736 public static function slugify(string $text): string
737 {
738 return sanitize_title($text);
739 }
740
741 /**
742 * Convert array to HTML attributes string
743 *
744 * @param array $attributes Key-value pairs
745 * @return string HTML attributes
746 */
747 public static function arrayToAttributes(array $attributes): string
748 {
749 $html = [];
750
751 foreach ($attributes as $key => $value) {
752 if ($value === true) {
753 $html[] = esc_attr($key);
754 } elseif ($value !== false && $value !== null) {
755 $html[] = esc_attr($key) . '="' . esc_attr($value) . '"';
756 }
757 }
758
759 return implode(' ', $html);
760 }
761
762 /**
763 * Sanitize Quill editor HTML output
764 *
765 * This function sanitizes HTML content from the Quill rich text editor,
766 * allowing only safe HTML tags and attributes that match the Quill toolbar configuration.
767 *
768 * Allowed features based on Quill toolbar:
769 * - Headers: h1, h2, h3
770 * - Text formatting: bold, italic, underline, strike
771 * - Lists: ordered (ol, li), unordered (ul, li)
772 * - Alignment: text-align attribute on p tags
773 * - Links: a tags with href attribute
774 * - Paragraphs: p tags
775 *
776 * @param string $html Raw HTML from Quill editor
777 * @return string Sanitized HTML safe for database storage
778 */
779 public static function sanitizeQuillHtml(string $html): string
780 {
781 // Return empty string if input is empty or just whitespace
782 if (empty(trim($html)) || $html === '<p><br></p>') {
783 return '';
784 }
785
786 // Define allowed HTML tags and attributes based on Quill configuration
787 $allowed_tags = [
788 // Headers (from Quill header dropdown: 1, 2, 3)
789 'h1' => [],
790 'h2' => [],
791 'h3' => [],
792
793 // Paragraphs with alignment support
794 'p' => [
795 'style' => true, // For text-align
796 'class' => true, // Quill may add alignment classes
797 ],
798
799 // Text formatting (bold, italic, underline, strike)
800 'strong' => [],
801 'b' => [],
802 'em' => [],
803 'i' => [],
804 'u' => [],
805 's' => [],
806 'strike' => [],
807
808 // Lists (ordered and unordered)
809 'ol' => [],
810 'ul' => [],
811 'li' => [],
812
813 // Links
814 'a' => [
815 'href' => true,
816 'title' => true,
817 'target' => true,
818 'rel' => true,
819 ],
820
821 // Line breaks
822 'br' => [],
823 ];
824
825 // Use wp_kses to sanitize with allowed tags
826 $sanitized = wp_kses($html, $allowed_tags);
827
828 // Additional cleanup for alignment styles
829 // Only allow text-align in style attribute
830 $sanitized = preg_replace_callback(
831 '/style="([^"]*)"/i',
832 function ($matches) {
833 $styles = $matches[1];
834 // Extract only text-align property
835 if (preg_match('/text-align:\s*(left|center|right|justify)/i', $styles, $align)) {
836 return 'style="text-align: ' . esc_attr($align[1]) . '"';
837 }
838 return ''; // Remove style attribute if no valid text-align
839 },
840 $sanitized
841 );
842
843 // Ensure links have proper rel attribute for security
844 $sanitized = preg_replace_callback(
845 '/<a\s+([^>]*?)>/i',
846 function ($matches) {
847 $attrs = $matches[1];
848 // If target="_blank" exists, ensure rel="noopener noreferrer"
849 if (stripos($attrs, 'target="_blank"') !== false) {
850 if (stripos($attrs, 'rel=') === false) {
851 $attrs .= ' rel="noopener noreferrer"';
852 } elseif (stripos($attrs, 'noopener') === false || stripos($attrs, 'noreferrer') === false) {
853 $attrs = preg_replace(
854 '/rel="([^"]*)"/i',
855 'rel="$1 noopener noreferrer"',
856 $attrs
857 );
858 }
859 }
860 return '<a ' . $attrs . '>';
861 },
862 $sanitized
863 );
864
865 // Remove empty paragraphs and normalize whitespace
866 $sanitized = preg_replace('/<p[^>]*>(\s|&nbsp;|<br\s*\/?>)*<\/p>/i', '', $sanitized);
867
868 // Trim whitespace
869 $sanitized = trim($sanitized);
870
871 return $sanitized;
872 }
873
874 /**
875 * Prepare Quill HTML for display (output escaping)
876 *
877 * Use this when outputting sanitized Quill content to the frontend.
878 * This assumes the content was already sanitized with sanitizeQuillHtml() before storage.
879 *
880 * @param string $html Sanitized HTML from database
881 * @return string HTML safe for display
882 */
883 public static function displayQuillHtml(string $html): string
884 {
885 if (empty($html)) {
886 return '';
887 }
888
889 // Apply WordPress content filters (auto-paragraphs, shortcodes, etc.)
890 // But skip wpautop since Quill already handles paragraphs
891 remove_filter('the_content', 'wpautop');
892 $content = apply_filters('the_content', $html);
893 add_filter('the_content', 'wpautop');
894
895 return $content;
896 }
897
898 /**
899 * Strip all HTML tags from Quill content (for excerpts, meta descriptions, etc.)
900 *
901 * @param string $html Quill HTML content
902 * @return string Plain text
903 */
904 public static function quillToPlainText(string $html): string
905 {
906 if (empty($html)) {
907 return '';
908 }
909
910 // Remove all HTML tags
911 $text = wp_strip_all_tags($html);
912
913 // Normalize whitespace
914 $text = preg_replace('/\s+/', ' ', $text);
915
916 // Trim
917 $text = trim($text);
918
919 return $text;
920 }
921 }
922
923