PluginProbe ʕ •ᴥ•ʔ
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.4.2
JetFormBuilder — Dynamic Blocks Form Builder v3.6.4.2
3.6.5.1 3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.4.1 1.4.2 1.4.3 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.1.0 2.1.1 2.1.10 2.1.11 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 3.0.0 3.0.0.1 3.0.0.2 3.0.0.3 3.0.1 3.0.1.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.0.1 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.3.2 3.3.3 3.3.3.1 3.3.4 3.3.4.1 3.3.4.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.5.1 3.4.5.2 3.4.6 3.4.7 3.4.7.1 3.5.0 3.5.1 3.5.1.1 3.5.1.2 3.5.2 3.5.2.1 3.5.3 3.5.4 3.5.5 3.5.6 3.5.6.1 3.5.6.2 3.5.6.3 3.6.0
jetformbuilder / modules / blocks-v2 / phone-field / assets / src / frontend / field / PhoneFieldData.js
jetformbuilder / modules / blocks-v2 / phone-field / assets / src / frontend / field Last commit date
PhoneFieldData.js 2 months ago i18n-loader.js 2 months ago index.js 4 months ago style.scss 4 months ago
PhoneFieldData.js
533 lines
1 import {
2 loadCurrentLocaleTranslations,
3 } from './i18n-loader';
4
5 const {
6 InputData,
7 } = window.JetFormBuilderAbstract;
8
9 /**
10 * International Phone Field Data Handler
11 *
12 * Handles intl-tel-input initialization, validation, and data processing
13 */
14 function PhoneFieldData() {
15 InputData.call( this );
16
17 this.itiInstance = null;
18 this.isInitialized = false;
19
20 /**
21 * Check if this handler supports the field
22 * @param node
23 */
24 this.isSupported = function ( node ) {
25 const supported = node.classList.contains( 'phone-field' );
26
27 return supported;
28 };
29
30 this.setNode = function ( node ) {
31 InputData.prototype.setNode.call( this, node );
32
33 this.initIntlTelInput();
34 };
35
36 /**
37 * Initialize intl-tel-input on the field
38 * Loads translations asynchronously before initialization
39 */
40 this.initIntlTelInput = async function () {
41 const node = this.nodes[ 0 ];
42 if ( ! node ) {
43 return;
44 }
45 if ( this.isInitialized ) {
46 return;
47 }
48
49 const wrapper = this.getWrapperNode();
50 const input = wrapper.querySelector('input.phone-field-intl');
51
52
53 // Get configuration from data attributes
54 const defaultCountry = node.dataset.defaultCountry || 'auto';
55 const preferredCountries = this.parseCountryList( node.dataset.preferredCountries );
56 const onlyCountries = this.parseCountryList( node.dataset.onlyCountries );
57 const excludeCountries = this.parseCountryList( node.dataset.excludeCountries );
58 const separateDialCode = node.dataset.separateDialCode || false;
59 const ipinfoToken = node.dataset.ipinfoToken || '';
60 // Determine initial country
61 let initialCountry = defaultCountry;
62
63 if ( initialCountry === 'auto' ) {
64 initialCountry = this.detectCountryByIP( ipinfoToken ) ||
65 this.detectCountryByLanguage() ||
66 'us';
67 }
68
69 // Load localized country names (async)
70 const i18n = await this.getLocalizedCountryNames();
71
72 // Build config object, only include arrays if they have items
73 const config = {
74 initialCountry,
75 separateDialCode: separateDialCode === '1',
76 strictMode: true,
77 nationalMode: true,
78 formatAsYouType: true,
79 formatOnDisplay: true,
80 autoPlaceholder: 'aggressive',
81 };
82
83 // Only add arrays if they have items
84 if ( preferredCountries.length ) {
85 config.countryOrder = preferredCountries;
86 }
87 if ( onlyCountries.length ) {
88 config.onlyCountries = onlyCountries;
89 }
90 if ( excludeCountries.length ) {
91 config.excludeCountries = excludeCountries;
92 }
93 if ( Object.keys( i18n ).length ) {
94 config.i18n = i18n;
95 }
96
97 // Initialize intl-tel-input
98 this.itiInstance = window.intlTelInput( input, config );
99
100 this.itiInstance.setNumber( node.value );
101
102 this.isInitialized = true;
103
104 };
105
106 this.normalizeIfInternational = function( input ) {
107 const value = input.value.trim();
108
109 if ( !value ) { return; }
110 if ( '+' === value[ 0 ] ) {
111 this.itiInstance.setNumber( value );
112
113 this.setValue();
114 }
115 }
116
117 /**
118 * Parse comma-separated country list
119 * @param str
120 */
121 this.parseCountryList = function ( str ) {
122 if ( ! str ) {
123 return [];
124 }
125 return str.split( ',' ).map( c => c.trim().toLowerCase() ).filter( c => c );
126 };
127
128 /**
129 * Detect country by IP using ipinfo.io
130 * @param token
131 */
132 this.detectCountryByIP = function ( token ) {
133 // Use cached value if available
134 const cached = sessionStorage.getItem( 'jfb_detected_country' );
135
136 if ( cached ) {
137 return cached;
138 }
139
140 const url = token
141 ? `https://api.ipinfo.io/lite/me?token=${ token }`
142 : 'https://ipinfo.io/json';
143
144 fetch( url )
145 .then( response => response.json() )
146 .then( data => {
147 if ( data.country ) {
148
149 const country = data.country.toLowerCase();
150 sessionStorage.setItem( 'jfb_detected_country', country );
151
152 // Update field if still in auto mode
153 if ( this.itiInstance ) {
154 this.itiInstance.setCountry( country );
155 }
156 }
157 } )
158 .catch( () => {
159 } );
160
161 return null;
162 };
163
164 /**
165 * Detect country by browser language
166 */
167 this.detectCountryByLanguage = function () {
168 const lang = navigator.language || navigator.userLanguage;
169 if ( ! lang ) {
170 return null;
171 }
172
173 // Extract country code from language (e.g., en-US -> us)
174 const parts = lang.split( '-' );
175 if ( parts.length > 1 ) {
176 return parts[ 1 ].toLowerCase();
177 }
178
179 // Language to country mapping
180 const langToCountry = {
181 'en': 'us',
182 'ru': 'ru',
183 'uk': 'ua',
184 'de': 'de',
185 'fr': 'fr',
186 'es': 'es',
187 'it': 'it',
188 'pl': 'pl',
189 'pt': 'br',
190 'ja': 'jp',
191 'ko': 'kr',
192 'zh': 'cn',
193 'ar': 'sa',
194 'he': 'il',
195 'hi': 'in',
196 };
197
198 return langToCountry[ parts[ 0 ].toLowerCase() ] || null;
199 };
200
201 /**
202 * Get localized country names
203 *
204 * Priority order:
205 * 1. Custom WordPress translations (via wp_localize_script and filters)
206 * 2. intl-tel-input library translations (loaded dynamically)
207 * 3. intl-tel-input built-in English names (fallback)
208 */
209 this.getLocalizedCountryNames = async function () {
210
211 // Get translations provided by WordPress (via wp_localize_script)
212 const wpTranslations = typeof window.jfbPhoneFieldI18n !== 'undefined'
213 ? window.jfbPhoneFieldI18n
214 : {};
215
216
217 // Load intl-tel-input library translations based on WordPress locale
218 let libraryTranslations = {};
219 try {
220 libraryTranslations = await loadCurrentLocaleTranslations();
221 } catch {
222 }
223
224 // Merge translations: WordPress overrides library translations
225 const mergedTranslations = {
226 ...libraryTranslations,
227 ...wpTranslations,
228 };
229
230 return mergedTranslations;
231 };
232
233 /**
234 * Add event listeners
235 */
236 this.addListeners = function () {
237 InputData.prototype.addListeners.call( this );
238
239 const node = this.nodes[ 0 ]; // Main field (phone)
240 if ( ! node ) {
241 return;
242 }
243
244 const wrapper = this.getWrapperNode();
245 const input = wrapper.querySelector('input.phone-field-intl'); // intl-tel-input field
246
247 if ( ! input ) {
248 return;
249 }
250
251 // Add form submit listener to validate before submission
252 const form = node.closest('form');
253 if ( form ) {
254 form.addEventListener('submit', () => {
255 this.normalizeIfInternational( input );
256 this.syncFromIntlInput( input, node );
257
258 // Validate and show error before submit
259 this.validateAndShowError();
260 }, true); // Use capture phase to run before JetFormBuilder
261 }
262
263 // Listen for country change
264 input.addEventListener( 'countrychange', () => {
265 this.syncFromIntlInput( input, node );
266
267 if ( wrapper.classList.contains( 'field-has-error' ) ) {
268 this.validateAndShowError();
269 }
270 } );
271
272 // Listen for input
273 input.addEventListener( 'input', () => {
274 this.normalizeIfInternational( input );
275 this.syncFromIntlInput( input, node );
276 // Clear error while typing
277 if ( input.value.trim() ) {
278 this.clearError();
279 }
280 } );
281
282 input.addEventListener('change', () => {
283 this.normalizeIfInternational( input );
284 this.syncFromIntlInput( input, node );
285 });
286
287 // Listen for blur (validate on blur)
288 input.addEventListener( 'blur', () => {
289 setTimeout(() => {
290 this.normalizeIfInternational( input );
291 this.syncFromIntlInput( input, node );
292 this.validateAndShowError();
293 }, 0);
294 } );
295 };
296
297 /**
298 * Sync value from intl-tel-input field to main field
299 * @param intlInput
300 * @param mainField
301 */
302 // eslint-disable-next-line no-unused-vars
303 this.syncFromIntlInput = function ( intlInput, mainField ) {
304 if ( ! this.itiInstance ) {
305 return;
306 }
307
308 // Determine save format from data attribute
309 const saveFormat = mainField.dataset.saveFormat || 'e164';
310
311 let formattedNumber;
312
313 // Get formatted number based on save format
314 switch ( saveFormat ) {
315 case 'international':
316 formattedNumber = this.itiInstance.getNumber( window.intlTelInput.utils.numberFormat.INTERNATIONAL );
317 break;
318 case 'e164':
319 default:
320 formattedNumber = this.itiInstance.getNumber(); // E.164 is default
321 break;
322 }
323
324 // Update main field value (this is what gets submitted)
325 mainField.value = formattedNumber || '';
326
327 // Update JetFormBuilder's internal tracking
328 this.calcValue = formattedNumber || '';
329 this.value.current = formattedNumber || '';
330 };
331
332 /**
333 * Show error message under the phone field
334 * @param message
335 */
336 this.showError = function ( message ) {
337 const wrapper = this.getWrapperNode();
338
339 if ( ! wrapper ) {
340 return;
341 }
342
343 // Remove existing error first
344 this.clearError();
345
346 // Add error class to wrapper
347 wrapper.classList.add( 'field-has-error' );
348
349 // Create error element
350 const errorDiv = document.createElement( 'div' );
351
352 errorDiv.className = 'error-message jet-form-builder__error';
353 errorDiv.textContent = message;
354
355 // Find where to insert error
356 const colEnd = wrapper.querySelector( '.jet-form-builder-col__end' );
357 if ( colEnd ) {
358 colEnd.appendChild( errorDiv );
359 } else {
360 wrapper.appendChild( errorDiv );
361 }
362
363 // Mark intl input as invalid
364 const intlInput = wrapper.querySelector( 'input.phone-field-intl' );
365 if ( intlInput ) {
366 intlInput.classList.add( 'invalid' );
367 intlInput.setAttribute( 'aria-invalid', 'true' );
368 }
369
370 };
371
372 /**
373 * Clear error message
374 */
375 this.clearError = function () {
376 const wrapper = this.getWrapperNode();
377 if ( ! wrapper ) {
378 return;
379 }
380
381 // Remove error class
382 wrapper.classList.remove( 'field-has-error' );
383
384 // Remove error message
385 const errorDiv = wrapper.querySelector( '.error-message' );
386 if ( errorDiv ) {
387 errorDiv.remove();
388 }
389
390 // Remove invalid class from intl input
391 const intlInput = wrapper.querySelector( 'input.phone-field-intl' );
392 if ( intlInput ) {
393 intlInput.classList.remove( 'invalid' );
394 intlInput.removeAttribute( 'aria-invalid' );
395 }
396
397 };
398
399 /**
400 * Get validation message from data-attributes or fallback
401 * @param type
402 */
403 this.getValidationMessage = function ( type ) {
404 const mainField = this.nodes[ 0 ];
405
406 // Get custom message from block settings (data-attributes)
407 if ( type === 'required' && mainField.dataset.validationMessageRequired ) {
408 return mainField.dataset.validationMessageRequired;
409 }
410 if ( type === 'invalid' && mainField.dataset.validationMessageInvalid ) {
411 return mainField.dataset.validationMessageInvalid;
412 }
413
414 // Fallback to default messages
415 return type === 'required'
416 ? 'This field is required'
417 : 'Please enter a valid phone number';
418 };
419
420 /**
421 * Validate phone number and show error if invalid
422 */
423 this.validateAndShowError = function () {
424 const wrapper = this.getWrapperNode();
425 const intlInput = wrapper?.querySelector( 'input.phone-field-intl' );
426 const mainField = this.nodes[ 0 ];
427
428 if ( ! intlInput ) {
429 return true;
430 }
431
432 const value = intlInput.value.trim();
433
434 // Check if field is required and empty
435 if ( mainField.hasAttribute( 'required' ) && ! value ) {
436 this.showError( this.getValidationMessage( 'required' ) );
437
438 return false;
439 }
440
441 // If field is not empty, validate phone number
442 if ( value && this.itiInstance ) {
443 const isValid = this.itiInstance.isValidNumber();
444
445 if ( ! isValid ) {
446 this.showError( this.getValidationMessage( 'invalid' ) );
447
448 return false;
449 }
450 }
451
452 // Clear error if valid
453 this.clearError();
454
455 return true;
456 };
457
458 /**
459 * Override getReportingNode to show errors under intl-tel-input field
460 * Used by BrowserReporting
461 */
462 this.getReportingNode = function () {
463 const wrapper = this.getWrapperNode();
464 const intlInput = wrapper?.querySelector('input.phone-field-intl');
465
466 // Return intl input if found, otherwise return main node
467 return intlInput || this.nodes[ 0 ];
468 };
469
470 /**
471 * Override getWrapperNode to ensure correct wrapper is returned
472 */
473 this.getWrapperNode = function () {
474 const node = this.nodes[ 0 ];
475 if ( ! node ) {
476 return null;
477 }
478
479 // Find the .jet-form-builder-row wrapper
480 const wrapper = node.closest( '.jet-form-builder-row' );
481
482 return wrapper;
483 };
484
485 /**
486 * Override setValue to get value from intl-tel-input
487 */
488 PhoneFieldData.prototype.setValue = function () {
489 const node = this.nodes[ 0 ]; // Main field
490 if ( ! node ) {
491 return '';
492 }
493
494 if ( ! this.itiInstance ) {
495 // If intl-tel-input not initialized yet, use node value
496 this.calcValue = node.value;
497 this.value.current = node.value;
498 return node.value;
499 }
500
501 // Determine save format from data attribute
502 const saveFormat = node.dataset.saveFormat || 'e164';
503
504 let fieldValue;
505
506 // Get formatted number based on save format
507 switch ( saveFormat ) {
508 case 'international':
509 fieldValue = this.itiInstance.getNumber( window.intlTelInputUtils.numberFormat.INTERNATIONAL );
510 break;
511 case 'e164':
512 default:
513 fieldValue = this.itiInstance.getNumber(); // E.164 is default
514 break;
515 }
516
517
518 node.value = fieldValue || '';
519
520 this.calcValue = fieldValue || '';
521 this.value.current = fieldValue || '';
522
523 return fieldValue;
524 };
525
526 }
527
528 // Set up prototype chain
529 PhoneFieldData.prototype = Object.create( InputData.prototype );
530 PhoneFieldData.prototype.constructor = PhoneFieldData;
531
532 export default PhoneFieldData;
533