PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
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 2.0.10 All 83 releases
yatra / app / Helpers / FormatHelper.php

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

999 lines 42.0 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 * Canonical country list — single source of truth used by every
385 * country / nationality dropdown in both Free and Pro plugins.
386 *
387 * Returns the full ISO-3166-1 alpha-2 set (sovereign states +
388 * dependent territories + commonly-accepted regions). Sorted
389 * alphabetically by name so the rendered dropdown is browseable
390 * without operators having to scan a code-ordered list.
391 *
392 * Filterable via `yatra_countries_list` for operators that want
393 * to:
394 * - Prepend "popular" entries (US, GB, IN, etc.) above an
395 * `---` separator for faster picking
396 * - Remove territories that don't apply to their market
397 * - Rename a region (e.g. business-language preferences)
398 *
399 * @return array<string, string> [ISO-3166 code => display name]
400 */
401 public static function getCountries(): array
402 {
403 $countries = self::countryNames();
404
405 // Stable alphabetical sort by display name. Operators expect
406 // "Argentina" before "Australia" before "Austria" — code-order
407 // (AR/AU/AT) is computer-friendly but not human-friendly.
408 asort($countries, SORT_STRING | SORT_FLAG_CASE);
409
410 if (\function_exists('apply_filters')) {
411 $filtered = apply_filters('yatra_countries_list', $countries);
412 if (\is_array($filtered) && $filtered !== []) {
413 return $filtered;
414 }
415 }
416 return $countries;
417 }
418
419 /**
420 * International dialing codes, keyed by ISO-3166-1 alpha-2 (digits only,
421 * no leading "+"). Aligned to the {@see countryNames()} set. Multiple
422 * countries legitimately share a code (all NANP countries use "1", RU/KZ
423 * use "7", GB/GG/IM/JE use "44") — {@see dialingCodePriority()} resolves
424 * which one an incoming "+code" auto-selects.
425 *
426 * @return array<string, string> [ISO code => dialing code]
427 */
428 private static function dialingCodes(): array
429 {
430 return [
431 'AF' => '93', 'AL' => '355', 'DZ' => '213', 'AD' => '376', 'AO' => '244', 'AI' => '1', 'AQ' => '672',
432 'AG' => '1', 'AR' => '54', 'AM' => '374', 'AW' => '297', 'AU' => '61', 'AT' => '43', 'AZ' => '994',
433 'BS' => '1', 'BH' => '973', 'BD' => '880', 'BB' => '1', 'BY' => '375', 'BE' => '32', 'BZ' => '501',
434 'BJ' => '229', 'BM' => '1', 'BT' => '975', 'BO' => '591', 'BQ' => '599', 'BA' => '387', 'BW' => '267',
435 'BR' => '55', 'IO' => '246', 'BN' => '673', 'BG' => '359', 'BF' => '226', 'BI' => '257', 'KH' => '855',
436 'CM' => '237', 'CA' => '1', 'CV' => '238', 'KY' => '1', 'CF' => '236', 'TD' => '235', 'CL' => '56',
437 'CN' => '86', 'CO' => '57', 'KM' => '269', 'CG' => '242', 'CD' => '243', 'CK' => '682', 'CR' => '506',
438 'CI' => '225', 'HR' => '385', 'CU' => '53', 'CW' => '599', 'CY' => '357', 'CZ' => '420', 'DK' => '45',
439 'DJ' => '253', 'DM' => '1', 'DO' => '1', 'EC' => '593', 'EG' => '20', 'SV' => '503', 'GQ' => '240',
440 'ER' => '291', 'EE' => '372', 'SZ' => '268', 'ET' => '251', 'FK' => '500', 'FO' => '298', 'FJ' => '679',
441 'FI' => '358', 'FR' => '33', 'GF' => '594', 'PF' => '689', 'GA' => '241', 'GM' => '220', 'GE' => '995',
442 'DE' => '49', 'GH' => '233', 'GI' => '350', 'GR' => '30', 'GL' => '299', 'GD' => '1', 'GP' => '590',
443 'GU' => '1', 'GT' => '502', 'GG' => '44', 'GN' => '224', 'GW' => '245', 'GY' => '592', 'HT' => '509',
444 'HN' => '504', 'HK' => '852', 'HU' => '36', 'IS' => '354', 'IN' => '91', 'ID' => '62', 'IR' => '98',
445 'IQ' => '964', 'IE' => '353', 'IM' => '44', 'IL' => '972', 'IT' => '39', 'JM' => '1', 'JP' => '81',
446 'JE' => '44', 'JO' => '962', 'KZ' => '7', 'KE' => '254', 'KI' => '686', 'KP' => '850', 'KR' => '82',
447 'KW' => '965', 'KG' => '996', 'LA' => '856', 'LV' => '371', 'LB' => '961', 'LS' => '266', 'LR' => '231',
448 'LY' => '218', 'LI' => '423', 'LT' => '370', 'LU' => '352', 'MO' => '853', 'MK' => '389', 'MG' => '261',
449 'MW' => '265', 'MY' => '60', 'MV' => '960', 'ML' => '223', 'MT' => '356', 'MH' => '692', 'MQ' => '596',
450 'MR' => '222', 'MU' => '230', 'YT' => '262', 'MX' => '52', 'FM' => '691', 'MD' => '373', 'MC' => '377',
451 'MN' => '976', 'ME' => '382', 'MS' => '1', 'MA' => '212', 'MZ' => '258', 'MM' => '95', 'NA' => '264',
452 'NR' => '674', 'NP' => '977', 'NL' => '31', 'NC' => '687', 'NZ' => '64', 'NI' => '505', 'NE' => '227',
453 'NG' => '234', 'NU' => '683', 'NF' => '672', 'MP' => '1', 'NO' => '47', 'OM' => '968', 'PK' => '92',
454 'PW' => '680', 'PS' => '970', 'PA' => '507', 'PG' => '675', 'PY' => '595', 'PE' => '51', 'PH' => '63',
455 'PN' => '64', 'PL' => '48', 'PT' => '351', 'PR' => '1', 'QA' => '974', 'RE' => '262', 'RO' => '40',
456 'RU' => '7', 'RW' => '250', 'BL' => '590', 'SH' => '290', 'KN' => '1', 'LC' => '1', 'MF' => '590',
457 'PM' => '508', 'VC' => '1', 'WS' => '685', 'SM' => '378', 'ST' => '239', 'SA' => '966', 'SN' => '221',
458 'RS' => '381', 'SC' => '248', 'SL' => '232', 'SG' => '65', 'SX' => '1', 'SK' => '421', 'SI' => '386',
459 'SB' => '677', 'SO' => '252', 'ZA' => '27', 'GS' => '500', 'SS' => '211', 'ES' => '34', 'LK' => '94',
460 'SD' => '249', 'SR' => '597', 'SJ' => '47', 'SE' => '46', 'CH' => '41', 'SY' => '963', 'TW' => '886',
461 'TJ' => '992', 'TZ' => '255', 'TH' => '66', 'TL' => '670', 'TG' => '228', 'TK' => '690', 'TO' => '676',
462 'TT' => '1', 'TN' => '216', 'TR' => '90', 'TM' => '993', 'TC' => '1', 'TV' => '688', 'UG' => '256',
463 'UA' => '380', 'AE' => '971', 'GB' => '44', 'US' => '1', 'UY' => '598', 'UZ' => '998', 'VU' => '678',
464 'VA' => '39', 'VE' => '58', 'VN' => '84', 'VG' => '1', 'VI' => '1', 'WF' => '681', 'EH' => '212',
465 'YE' => '967', 'ZM' => '260', 'ZW' => '263', 'XK' => '383', 'AS' => '1', 'AX' => '358',
466 'UM' => '1', 'CX' => '61', 'CC' => '61', 'TA' => '290',
467 ];
468 }
469
470 /**
471 * Preferred country for a dialing code that several countries share. When
472 * an operator types a raw "+<code><number>" we must pick one country to
473 * auto-select; this is the conventional primary (e.g. "1" => US, not one of
474 * the ~20 other NANP countries). Codes not listed here fall back to the
475 * first ISO that declares them.
476 *
477 * @return array<string, string> [dialing code => primary ISO]
478 */
479 private static function dialingCodePriority(): array
480 {
481 return [
482 '1' => 'US', '7' => 'RU', '39' => 'IT', '44' => 'GB', '47' => 'NO', '61' => 'AU',
483 '212' => 'MA', '262' => 'RE', '290' => 'SH', '358' => 'FI', '500' => 'FK',
484 '590' => 'GP', '599' => 'CW', '64' => 'NZ', '672' => 'AQ',
485 ];
486 }
487
488 /**
489 * Preferred ISO per shared dialing code (dial => ISO), for the front-end
490 * detector to resolve which country a "+code" auto-selects. Mirrors the
491 * server-side {@see dialingCodePriority()}.
492 *
493 * @return array<string, string>
494 */
495 public static function getPhonePriority(): array
496 {
497 return self::dialingCodePriority();
498 }
499
500 /**
501 * Dialing code (digits only, no "+") for one ISO code, or '' if unknown.
502 */
503 public static function getDialingCode(string $iso): string
504 {
505 $codes = self::dialingCodes();
506 return $codes[strtoupper($iso)] ?? '';
507 }
508
509 /**
510 * Country list for the phone-number field: ISO code, display name and
511 * dialing code, sorted by display name. Filterable via
512 * `yatra_phone_countries` (e.g. to pin popular countries to the top).
513 *
514 * @return array<int, array{iso:string, name:string, dial:string}>
515 */
516 public static function getPhoneCountries(): array
517 {
518 $names = self::getCountries(); // [iso => name], already sorted/filtered
519 $dial = self::dialingCodes();
520 $out = [];
521 foreach ($names as $iso => $name) {
522 $iso = strtoupper((string) $iso);
523 if (!isset($dial[$iso]) || $dial[$iso] === '') {
524 continue;
525 }
526 $out[] = ['iso' => $iso, 'name' => (string) $name, 'dial' => $dial[$iso]];
527 }
528 if (\function_exists('apply_filters')) {
529 $filtered = apply_filters('yatra_phone_countries', $out);
530 if (\is_array($filtered) && $filtered !== []) {
531 return $filtered;
532 }
533 }
534 return $out;
535 }
536
537 /**
538 * Detect the country/dialing-code from a raw phone value. Only a value that
539 * begins with "+" is treated as international; anything else returns null so
540 * an existing bare national number is left untouched (backward compatible).
541 *
542 * Uses longest-prefix matching so "+9779806015400" → NP (977), not a shorter
543 * false match, then applies {@see dialingCodePriority()} for shared codes.
544 *
545 * @return array{iso:string, dial:string, national:string}|null
546 */
547 public static function detectPhoneCountry(string $raw): ?array
548 {
549 $raw = trim($raw);
550 if ($raw === '' || $raw[0] !== '+') {
551 return null;
552 }
553 $digits = preg_replace('/\D+/', '', $raw);
554 if ($digits === '' || $digits === null) {
555 return null;
556 }
557 $codes = self::dialingCodes();
558 $priority = self::dialingCodePriority();
559 $maxLen = min(4, strlen($digits));
560 for ($len = $maxLen; $len >= 1; $len--) {
561 $prefix = substr($digits, 0, $len);
562 $matches = array_keys($codes, $prefix, true);
563 if ($matches === []) {
564 continue;
565 }
566 $iso = $priority[$prefix] ?? (string) $matches[0];
567 return [
568 'iso' => $iso,
569 'dial' => $prefix,
570 'national' => substr($digits, $len),
571 ];
572 }
573 return null;
574 }
575
576 /**
577 * Default country pre-selected in the phone field. Priority: the
578 * `default_phone_country` setting → the site locale suffix (en_US → US,
579 * ne_NP → NP) → "US". Filterable via `yatra_default_phone_country`.
580 */
581 public static function getDefaultPhoneCountry(): string
582 {
583 $codes = self::dialingCodes();
584 $default = '';
585
586 if (\class_exists(SettingsService::class)) {
587 $setting = strtoupper((string) SettingsService::getString('default_phone_country', ''));
588 if ($setting !== '' && isset($codes[$setting])) {
589 $default = $setting;
590 }
591 }
592 if ($default === '' && \function_exists('get_locale')) {
593 if (preg_match('/_([A-Z]{2})$/', (string) get_locale(), $m) && isset($codes[$m[1]])) {
594 $default = $m[1];
595 }
596 }
597 if ($default === '') {
598 $default = 'US';
599 }
600 if (\function_exists('apply_filters')) {
601 $default = (string) apply_filters('yatra_default_phone_country', $default);
602 }
603 return isset($codes[$default]) ? $default : 'US';
604 }
605
606 /**
607 * Combine a national number with a country (ISO) into the stored value.
608 *
609 * - Empty number → '' (nothing to store).
610 * - Already-international ("+...") → returned unchanged (a pasted full number
611 * or the no-JS fallback where the customer typed the "+code" themselves).
612 * - National number + known ISO → "+<dial><digits>" (spaces/dashes stripped).
613 * - No/unknown ISO → the number left as typed (legacy behavior, backward
614 * compatible — nothing is invented).
615 */
616 public static function combineInternationalPhone(string $number, string $iso): string
617 {
618 $number = trim($number);
619 if ($number === '') {
620 return '';
621 }
622 if ($number[0] === '+') {
623 return $number;
624 }
625 $iso = strtoupper(trim($iso));
626 $dial = $iso !== '' ? self::getDialingCode($iso) : '';
627 if ($dial === '') {
628 return $number;
629 }
630 $digits = preg_replace('/\D+/', '', $number);
631 if ($digits === '' || $digits === null) {
632 return $number;
633 }
634 return '+' . $dial . $digits;
635 }
636
637 /**
638 * Format a stored phone value for human display. A value saved with a
639 * country code ("+9779806015400") is shown as "+977 9806015400"; a legacy
640 * bare value is returned unchanged (never corrupted). Optionally accepts a
641 * known ISO to disambiguate shared codes.
642 */
643 public static function formatPhoneForDisplay(string $stored, string $iso = ''): string
644 {
645 $stored = trim($stored);
646 if ($stored === '') {
647 return '';
648 }
649 $detected = self::detectPhoneCountry($stored);
650 if ($detected === null) {
651 return $stored; // legacy / national-only value — leave as-is
652 }
653 $national = $detected['national'] !== '' ? ' ' . $detected['national'] : '';
654 return '+' . $detected['dial'] . $national;
655 }
656
657 /**
658 * Format phone number for display
659 *
660 * @param string $phone Phone number
661 * @return string Formatted phone
662 */
663 public static function formatPhone(string $phone): string
664 {
665 // Remove non-numeric characters except + and spaces
666 $phone = preg_replace('/[^\d\+\s\-\(\)]/', '', $phone);
667 return trim($phone);
668 }
669
670 /**
671 * Format duration (days/nights)
672 *
673 * @param int $days Number of days
674 * @param int|null $nights Number of nights (optional)
675 * @return string Formatted duration
676 */
677 public static function formatDuration(int $days, ?int $nights = null): string
678 {
679 if ($nights !== null) {
680 return sprintf(
681 _n('%d Day', '%d Days', $days, 'yatra'),
682 $days
683 ) . ' / ' . sprintf(
684 _n('%d Night', '%d Nights', $nights, 'yatra'),
685 $nights
686 );
687 }
688
689 return sprintf(_n('%d Day', '%d Days', $days, 'yatra'), $days);
690 }
691
692 /**
693 * Format file size
694 *
695 * @param int $bytes File size in bytes
696 * @return string Formatted size
697 */
698 public static function formatFileSize(int $bytes): string
699 {
700 $units = ['B', 'KB', 'MB', 'GB', 'TB'];
701 $unitIndex = 0;
702
703 while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
704 $bytes /= 1024;
705 $unitIndex++;
706 }
707
708 return round($bytes, 2) . ' ' . $units[$unitIndex];
709 }
710
711 /**
712 * Truncate text with ellipsis
713 *
714 * @param string $text Text to truncate
715 * @param int $length Maximum length
716 * @param string $suffix Suffix to add (default: ...)
717 * @return string Truncated text
718 */
719 public static function truncate(string $text, int $length = 100, string $suffix = '...'): string
720 {
721 if (mb_strlen($text) <= $length) {
722 return $text;
723 }
724
725 return mb_substr($text, 0, $length - mb_strlen($suffix)) . $suffix;
726 }
727
728 /**
729 * Generate excerpt from HTML content
730 *
731 * @param string $html HTML content
732 * @param int $length Maximum length
733 * @return string Plain text excerpt
734 */
735 public static function excerpt(string $html, int $length = 150): string
736 {
737 $text = wp_strip_all_tags($html);
738 $text = preg_replace('/\s+/', ' ', $text);
739 $text = trim($text);
740
741 return self::truncate($text, $length);
742 }
743
744 /**
745 * Format rating as stars HTML
746 *
747 * @param float $rating Rating (0-5)
748 * @param bool $showNumber Show rating number
749 * @return string HTML stars
750 */
751 public static function formatRatingStars(float $rating, bool $showNumber = false): string
752 {
753 $rating = max(0, min(5, $rating));
754 $fullStars = (int) floor($rating);
755 $hasHalfStar = ($rating - $fullStars) >= 0.5;
756
757 $html = '<span class="yatra-rating-stars">';
758
759 for ($i = 1; $i <= 5; $i++) {
760 if ($i <= $fullStars) {
761 $html .= '<span class="yatra-star filled">�
762 </span>';
763 } elseif ($i === $fullStars + 1 && $hasHalfStar) {
764 $html .= '<span class="yatra-star half">�
765 </span>';
766 } else {
767 $html .= '<span class="yatra-star">�
768 </span>';
769 }
770 }
771
772 $html .= '</span>';
773
774 if ($showNumber) {
775 $html .= '<span class="yatra-rating-number">' . number_format($rating, 1) . '</span>';
776 }
777
778 return $html;
779 }
780
781 /**
782 * Format booking status as badge
783 *
784 * @param string $status Booking status
785 * @return string HTML badge
786 */
787 public static function formatStatusBadge(string $status): string
788 {
789 $statusClasses = [
790 'pending' => 'yatra-badge yatra-badge-warning',
791 'confirmed' => 'yatra-badge yatra-badge-success',
792 'processing' => 'yatra-badge yatra-badge-info',
793 'completed' => 'yatra-badge yatra-badge-success',
794 'cancelled' => 'yatra-badge yatra-badge-danger',
795 'refunded' => 'yatra-badge yatra-badge-secondary',
796 'failed' => 'yatra-badge yatra-badge-danger',
797 'on_hold' => 'yatra-badge yatra-badge-warning',
798 ];
799
800 $class = $statusClasses[$status] ?? 'yatra-badge';
801 $label = ucfirst(str_replace('_', ' ', $status));
802
803 return '<span class="' . esc_attr($class) . '">' . esc_html($label) . '</span>';
804 }
805
806 /**
807 * Sanitize and format slug
808 *
809 * @param string $text Text to slugify
810 * @return string URL-safe slug
811 */
812 public static function slugify(string $text): string
813 {
814 return sanitize_title($text);
815 }
816
817 /**
818 * Convert array to HTML attributes string
819 *
820 * @param array $attributes Key-value pairs
821 * @return string HTML attributes
822 */
823 public static function arrayToAttributes(array $attributes): string
824 {
825 $html = [];
826
827 foreach ($attributes as $key => $value) {
828 if ($value === true) {
829 $html[] = esc_attr($key);
830 } elseif ($value !== false && $value !== null) {
831 $html[] = esc_attr($key) . '="' . esc_attr($value) . '"';
832 }
833 }
834
835 return implode(' ', $html);
836 }
837
838 /**
839 * Sanitize Quill editor HTML output
840 *
841 * This function sanitizes HTML content from the Quill rich text editor,
842 * allowing only safe HTML tags and attributes that match the Quill toolbar configuration.
843 *
844 * Allowed features based on Quill toolbar:
845 * - Headers: h1, h2, h3
846 * - Text formatting: bold, italic, underline, strike
847 * - Lists: ordered (ol, li), unordered (ul, li)
848 * - Alignment: text-align attribute on p tags
849 * - Links: a tags with href attribute
850 * - Paragraphs: p tags
851 *
852 * @param string $html Raw HTML from Quill editor
853 * @return string Sanitized HTML safe for database storage
854 */
855 public static function sanitizeQuillHtml(string $html): string
856 {
857 // Return empty string if input is empty or just whitespace
858 if (empty(trim($html)) || $html === '<p><br></p>') {
859 return '';
860 }
861
862 // Define allowed HTML tags and attributes based on Quill configuration
863 $allowed_tags = [
864 // Headers (from Quill header dropdown: 1, 2, 3)
865 'h1' => [],
866 'h2' => [],
867 'h3' => [],
868
869 // Paragraphs with alignment support
870 'p' => [
871 'style' => true, // For text-align
872 'class' => true, // Quill may add alignment classes
873 ],
874
875 // Text formatting (bold, italic, underline, strike)
876 'strong' => [],
877 'b' => [],
878 'em' => [],
879 'i' => [],
880 'u' => [],
881 's' => [],
882 'strike' => [],
883
884 // Lists (ordered and unordered)
885 'ol' => [],
886 'ul' => [],
887 'li' => [],
888
889 // Links
890 'a' => [
891 'href' => true,
892 'title' => true,
893 'target' => true,
894 'rel' => true,
895 ],
896
897 // Line breaks
898 'br' => [],
899 ];
900
901 // Use wp_kses to sanitize with allowed tags
902 $sanitized = wp_kses($html, $allowed_tags);
903
904 // Additional cleanup for alignment styles
905 // Only allow text-align in style attribute
906 $sanitized = preg_replace_callback(
907 '/style="([^"]*)"/i',
908 function ($matches) {
909 $styles = $matches[1];
910 // Extract only text-align property
911 if (preg_match('/text-align:\s*(left|center|right|justify)/i', $styles, $align)) {
912 return 'style="text-align: ' . esc_attr($align[1]) . '"';
913 }
914 return ''; // Remove style attribute if no valid text-align
915 },
916 $sanitized
917 );
918
919 // Ensure links have proper rel attribute for security
920 $sanitized = preg_replace_callback(
921 '/<a\s+([^>]*?)>/i',
922 function ($matches) {
923 $attrs = $matches[1];
924 // If target="_blank" exists, ensure rel="noopener noreferrer"
925 if (stripos($attrs, 'target="_blank"') !== false) {
926 if (stripos($attrs, 'rel=') === false) {
927 $attrs .= ' rel="noopener noreferrer"';
928 } elseif (stripos($attrs, 'noopener') === false || stripos($attrs, 'noreferrer') === false) {
929 $attrs = preg_replace(
930 '/rel="([^"]*)"/i',
931 'rel="$1 noopener noreferrer"',
932 $attrs
933 );
934 }
935 }
936 return '<a ' . $attrs . '>';
937 },
938 $sanitized
939 );
940
941 // Remove empty paragraphs and normalize whitespace
942 $sanitized = preg_replace('/<p[^>]*>(\s|&nbsp;|<br\s*\/?>)*<\/p>/i', '', $sanitized);
943
944 // Trim whitespace
945 $sanitized = trim($sanitized);
946
947 return $sanitized;
948 }
949
950 /**
951 * Prepare Quill HTML for display (output escaping)
952 *
953 * Use this when outputting sanitized Quill content to the frontend.
954 * This assumes the content was already sanitized with sanitizeQuillHtml() before storage.
955 *
956 * @param string $html Sanitized HTML from database
957 * @return string HTML safe for display
958 */
959 public static function displayQuillHtml(string $html): string
960 {
961 if (empty($html)) {
962 return '';
963 }
964
965 // Apply WordPress content filters (auto-paragraphs, shortcodes, etc.)
966 // But skip wpautop since Quill already handles paragraphs
967 remove_filter('the_content', 'wpautop');
968 $content = apply_filters('the_content', $html);
969 add_filter('the_content', 'wpautop');
970
971 return $content;
972 }
973
974 /**
975 * Strip all HTML tags from Quill content (for excerpts, meta descriptions, etc.)
976 *
977 * @param string $html Quill HTML content
978 * @return string Plain text
979 */
980 public static function quillToPlainText(string $html): string
981 {
982 if (empty($html)) {
983 return '';
984 }
985
986 // Remove all HTML tags
987 $text = wp_strip_all_tags($html);
988
989 // Normalize whitespace
990 $text = preg_replace('/\s+/', ' ', $text);
991
992 // Trim
993 $text = trim($text);
994
995 return $text;
996 }
997 }
998
999