PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Helpers/FormatHelper.php

1,059 lines 44.2 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 * 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 /**
434 * Canonical country list — single source of truth used by every
435 * country / nationality dropdown in both Free and Pro plugins.
436 *
437 * Returns the full ISO-3166-1 alpha-2 set (sovereign states +
438 * dependent territories + commonly-accepted regions). Sorted
439 * alphabetically by name so the rendered dropdown is browseable
440 * without operators having to scan a code-ordered list.
441 *
442 * Filterable via `yatra_countries_list` for operators that want
443 * to:
444 * - Prepend "popular" entries (US, GB, IN, etc.) above an
445 * `---` separator for faster picking
446 * - Remove territories that don't apply to their market
447 * - Rename a region (e.g. business-language preferences)
448 *
449 * @return array<string, string> [ISO-3166 code => display name]
450 */
451 public static function getCountries(): array
452 {
453 $countries = self::countryNames();
454
455 // Stable alphabetical sort by display name. Operators expect
456 // "Argentina" before "Australia" before "Austria" — code-order
457 // (AR/AU/AT) is computer-friendly but not human-friendly.
458 asort($countries, SORT_STRING | SORT_FLAG_CASE);
459
460 if (\function_exists('apply_filters')) {
461 $filtered = apply_filters('yatra_countries_list', $countries);
462 if (\is_array($filtered) && $filtered !== []) {
463 return $filtered;
464 }
465 }
466 return $countries;
467 }
468
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 /**
708 * Format phone number for display
709 *
710 * @param string $phone Phone number
711 * @return string Formatted phone
712 */
713 public static function formatPhone(string $phone): string
714 {
715 // Remove non-numeric characters except + and spaces
716 $phone = preg_replace('/[^\d\+\s\-\(\)]/', '', $phone);
717 return trim($phone);
718 }
719
720 /**
721 * Format duration (days/nights)
722 *
723 * @param int $days Number of days
724 * @param int|null $nights Number of nights (optional)
725 * @return string Formatted duration
726 */
727 public static function formatDuration(int $days, ?int $nights = null): string
728 {
729 if ($nights !== null) {
730 return sprintf(
731 _n('%d Day', '%d Days', $days, 'yatra'),
732 $days
733 ) . ' / ' . sprintf(
734 _n('%d Night', '%d Nights', $nights, 'yatra'),
735 $nights
736 );
737 }
738
739 return sprintf(_n('%d Day', '%d Days', $days, 'yatra'), $days);
740 }
741
742 /**
743 * Format file size
744 *
745 * @param int $bytes File size in bytes
746 * @return string Formatted size
747 */
748 public static function formatFileSize(int $bytes): string
749 {
750 $units = ['B', 'KB', 'MB', 'GB', 'TB'];
751 $unitIndex = 0;
752
753 while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
754 $bytes /= 1024;
755 $unitIndex++;
756 }
757
758 return round($bytes, 2) . ' ' . $units[$unitIndex];
759 }
760
761 /**
762 * Truncate text with ellipsis
763 *
764 * @param string $text Text to truncate
765 * @param int $length Maximum length
766 * @param string $suffix Suffix to add (default: ...)
767 * @return string Truncated text
768 */
769 public static function truncate(string $text, int $length = 100, string $suffix = '...'): string
770 {
771 if (mb_strlen($text) <= $length) {
772 return $text;
773 }
774
775 return mb_substr($text, 0, $length - mb_strlen($suffix)) . $suffix;
776 }
777
778 /**
779 * Generate excerpt from HTML content
780 *
781 * @param string $html HTML content
782 * @param int $length Maximum length
783 * @return string Plain text excerpt
784 */
785 public static function excerpt(string $html, int $length = 150): string
786 {
787 $text = wp_strip_all_tags($html);
788 $text = preg_replace('/\s+/', ' ', $text);
789 $text = trim($text);
790
791 return self::truncate($text, $length);
792 }
793
794 /**
795 * Format rating as stars HTML
796 *
797 * @param float $rating Rating (0-5)
798 * @param bool $showNumber Show rating number
799 * @return string HTML stars
800 */
801 public static function formatRatingStars(float $rating, bool $showNumber = false): string
802 {
803 $rating = max(0, min(5, $rating));
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 }
816
817 $html = '<span class="yatra-rating-stars">';
818
819 for ($i = 1; $i <= 5; $i++) {
820 if ($i <= $fullStars) {
821 $html .= '<span class="yatra-star filled">�
822 </span>';
823 } elseif ($i === $fullStars + 1 && $hasHalfStar) {
824 $html .= '<span class="yatra-star half">�
825 </span>';
826 } else {
827 $html .= '<span class="yatra-star">�
828 </span>';
829 }
830 }
831
832 $html .= '</span>';
833
834 if ($showNumber) {
835 $html .= '<span class="yatra-rating-number">' . number_format($rating, 1) . '</span>';
836 }
837
838 return $html;
839 }
840
841 /**
842 * Format booking status as badge
843 *
844 * @param string $status Booking status
845 * @return string HTML badge
846 */
847 public static function formatStatusBadge(string $status): string
848 {
849 $statusClasses = [
850 'pending' => 'yatra-badge yatra-badge-warning',
851 'confirmed' => 'yatra-badge yatra-badge-success',
852 'processing' => 'yatra-badge yatra-badge-info',
853 'completed' => 'yatra-badge yatra-badge-success',
854 'cancelled' => 'yatra-badge yatra-badge-danger',
855 'refunded' => 'yatra-badge yatra-badge-secondary',
856 'failed' => 'yatra-badge yatra-badge-danger',
857 'on_hold' => 'yatra-badge yatra-badge-warning',
858 ];
859
860 $class = $statusClasses[$status] ?? 'yatra-badge';
861 $label = ucfirst(str_replace('_', ' ', $status));
862
863 return '<span class="' . esc_attr($class) . '">' . esc_html($label) . '</span>';
864 }
865
866 /**
867 * Sanitize and format slug
868 *
869 * @param string $text Text to slugify
870 * @return string URL-safe slug
871 */
872 public static function slugify(string $text): string
873 {
874 return sanitize_title($text);
875 }
876
877 /**
878 * Convert array to HTML attributes string
879 *
880 * @param array $attributes Key-value pairs
881 * @return string HTML attributes
882 */
883 public static function arrayToAttributes(array $attributes): string
884 {
885 $html = [];
886
887 foreach ($attributes as $key => $value) {
888 if ($value === true) {
889 $html[] = esc_attr($key);
890 } elseif ($value !== false && $value !== null) {
891 $html[] = esc_attr($key) . '="' . esc_attr($value) . '"';
892 }
893 }
894
895 return implode(' ', $html);
896 }
897
898 /**
899 * Sanitize Quill editor HTML output
900 *
901 * This function sanitizes HTML content from the Quill rich text editor,
902 * allowing only safe HTML tags and attributes that match the Quill toolbar configuration.
903 *
904 * Allowed features based on Quill toolbar:
905 * - Headers: h1, h2, h3
906 * - Text formatting: bold, italic, underline, strike
907 * - Lists: ordered (ol, li), unordered (ul, li)
908 * - Alignment: text-align attribute on p tags
909 * - Links: a tags with href attribute
910 * - Paragraphs: p tags
911 *
912 * @param string $html Raw HTML from Quill editor
913 * @return string Sanitized HTML safe for database storage
914 */
915 public static function sanitizeQuillHtml(string $html): string
916 {
917 // Return empty string if input is empty or just whitespace
918 if (empty(trim($html)) || $html === '<p><br></p>') {
919 return '';
920 }
921
922 // Define allowed HTML tags and attributes based on Quill configuration
923 $allowed_tags = [
924 // Headers (from Quill header dropdown: 1, 2, 3)
925 'h1' => [],
926 'h2' => [],
927 'h3' => [],
928
929 // Paragraphs with alignment support
930 'p' => [
931 'style' => true, // For text-align
932 'class' => true, // Quill may add alignment classes
933 ],
934
935 // Text formatting (bold, italic, underline, strike)
936 'strong' => [],
937 'b' => [],
938 'em' => [],
939 'i' => [],
940 'u' => [],
941 's' => [],
942 'strike' => [],
943
944 // Lists (ordered and unordered)
945 'ol' => [],
946 'ul' => [],
947 'li' => [],
948
949 // Links
950 'a' => [
951 'href' => true,
952 'title' => true,
953 'target' => true,
954 'rel' => true,
955 ],
956
957 // Line breaks
958 'br' => [],
959 ];
960
961 // Use wp_kses to sanitize with allowed tags
962 $sanitized = wp_kses($html, $allowed_tags);
963
964 // Additional cleanup for alignment styles
965 // Only allow text-align in style attribute
966 $sanitized = preg_replace_callback(
967 '/style="([^"]*)"/i',
968 function ($matches) {
969 $styles = $matches[1];
970 // Extract only text-align property
971 if (preg_match('/text-align:\s*(left|center|right|justify)/i', $styles, $align)) {
972 return 'style="text-align: ' . esc_attr($align[1]) . '"';
973 }
974 return ''; // Remove style attribute if no valid text-align
975 },
976 $sanitized
977 );
978
979 // Ensure links have proper rel attribute for security
980 $sanitized = preg_replace_callback(
981 '/<a\s+([^>]*?)>/i',
982 function ($matches) {
983 $attrs = $matches[1];
984 // If target="_blank" exists, ensure rel="noopener noreferrer"
985 if (stripos($attrs, 'target="_blank"') !== false) {
986 if (stripos($attrs, 'rel=') === false) {
987 $attrs .= ' rel="noopener noreferrer"';
988 } elseif (stripos($attrs, 'noopener') === false || stripos($attrs, 'noreferrer') === false) {
989 $attrs = preg_replace(
990 '/rel="([^"]*)"/i',
991 'rel="$1 noopener noreferrer"',
992 $attrs
993 );
994 }
995 }
996 return '<a ' . $attrs . '>';
997 },
998 $sanitized
999 );
1000
1001 // Remove empty paragraphs and normalize whitespace
1002 $sanitized = preg_replace('/<p[^>]*>(\s|&nbsp;|<br\s*\/?>)*<\/p>/i', '', $sanitized);
1003
1004 // Trim whitespace
1005 $sanitized = trim($sanitized);
1006
1007 return $sanitized;
1008 }
1009
1010 /**
1011 * Prepare Quill HTML for display (output escaping)
1012 *
1013 * Use this when outputting sanitized Quill content to the frontend.
1014 * This assumes the content was already sanitized with sanitizeQuillHtml() before storage.
1015 *
1016 * @param string $html Sanitized HTML from database
1017 * @return string HTML safe for display
1018 */
1019 public static function displayQuillHtml(string $html): string
1020 {
1021 if (empty($html)) {
1022 return '';
1023 }
1024
1025 // Apply WordPress content filters (auto-paragraphs, shortcodes, etc.)
1026 // But skip wpautop since Quill already handles paragraphs
1027 remove_filter('the_content', 'wpautop');
1028 $content = apply_filters('the_content', $html);
1029 add_filter('the_content', 'wpautop');
1030
1031 return $content;
1032 }
1033
1034 /**
1035 * Strip all HTML tags from Quill content (for excerpts, meta descriptions, etc.)
1036 *
1037 * @param string $html Quill HTML content
1038 * @return string Plain text
1039 */
1040 public static function quillToPlainText(string $html): string
1041 {
1042 if (empty($html)) {
1043 return '';
1044 }
1045
1046 // Remove all HTML tags
1047 $text = wp_strip_all_tags($html);
1048
1049 // Normalize whitespace
1050 $text = preg_replace('/\s+/', ' ', $text);
1051
1052 // Trim
1053 $text = trim($text);
1054
1055 return $text;
1056 }
1057 }
1058
1059