'phone_mode', 'type' => 'select', 'label' => __( 'Phone field type', '2ranq-forms' ), 'help' => __( 'Smart adds a country code in front of the field and checks how many digits that country uses.', '2ranq-forms' ), 'default' => 'plain', 'options' => array( 'plain' => __( 'Plain phone field', '2ranq-forms' ), 'smart' => __( 'Smart, with country code', '2ranq-forms' ), ), ); $countries = array(); foreach ( Ranq_Forms_Phone_Countries::all() as $code => $country ) { $countries[ $code ] = $country['name'] . ' (+' . $country['dial'] . ')'; } $settings['advanced'][] = array( 'key' => 'phone_country', 'type' => 'select', 'label' => __( 'Starting country', '2ranq-forms' ), 'help' => __( 'Smart field only. Used when the visitor country cannot be worked out from the request or from the site language.', '2ranq-forms' ), 'default' => Ranq_Forms_Phone_Countries::DEFAULT_COUNTRY, 'options' => $countries, ); return $settings; } /** * Whether the field is in smart mode. * * @param array $field The field settings. * * @return bool */ private function is_smart( $field ) { return isset( $field['phone_mode'] ) && 'smart' === $field['phone_mode']; } /** * Guesses the visitor's country. * * 🔴 Nothing leaves the site. There is no call to somebody else's service to * resolve a country from an address, because that would mean sending the * visitor's address to a third party, and the WordPress.org machine check * rejects such a submission anyway. * * The order, and why exactly this one: * * 1. The `CF-IPCountry` header. The only thing that knows where THIS visitor * is. It arrives only when the site sits behind Cloudflare, and it costs * no request at all. * 2. The site language. It is the same for every visitor, so it is weaker * evidence, but better than guessing. It is used ONLY when somebody * actually picked the language: `en_US` is the value a site has when * nobody touched anything, so it says nothing about the visitor and is * skipped. * 3. What the owner picked in the field settings. It comes last because it is * their assumption, while the first two are a measurement. * * ⚠️ When the page sits in a cache, the first step holds for whoever opened * the page first. That is why this is NOT a check but only a starting value: * the visitor changes the country with one click, and the server validates * what was actually sent. * * @param array $field The field settings. * * @return string The country letter code. */ private function guess_country( $field ) { // 1. Cloudflare. $header = isset( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ) : ''; if ( '' !== $header && Ranq_Forms_Phone_Countries::exists( $header ) ) { return Ranq_Forms_Phone_Countries::normalize_code( $header ); } // 2. The site language. $locale = get_locale(); if ( 'en_US' !== $locale ) { $code = $this->country_from_locale( $locale ); if ( '' !== $code ) { return $code; } } // 3. What the owner picked. $chosen = isset( $field['phone_country'] ) ? (string) $field['phone_country'] : ''; if ( Ranq_Forms_Phone_Countries::exists( $chosen ) ) { return Ranq_Forms_Phone_Countries::normalize_code( $chosen ); } return Ranq_Forms_Phone_Countries::DEFAULT_COUNTRY; } /** * The country out of a locale, for example `sr_RS` into `RS`. * * A language without a country (`de`) is resolved through a small list, * because guessing Germany is better than leaving the person on America. * * @param string $locale The site locale. * * @return string Empty when nothing can be concluded. */ private function country_from_locale( $locale ) { $locale = str_replace( '-', '_', (string) $locale ); $parts = explode( '_', $locale ); if ( isset( $parts[1] ) && Ranq_Forms_Phone_Countries::exists( $parts[1] ) ) { return Ranq_Forms_Phone_Countries::normalize_code( $parts[1] ); } $by_language = array( 'ar' => 'AE', 'bg' => 'BG', 'bs' => 'BA', 'cs' => 'CZ', 'da' => 'DK', 'de' => 'DE', 'el' => 'GR', 'es' => 'ES', 'et' => 'EE', 'fi' => 'FI', 'fr' => 'FR', 'he' => 'IL', 'hr' => 'HR', 'hu' => 'HU', 'id' => 'ID', 'it' => 'IT', 'ja' => 'JP', 'ko' => 'KR', 'lt' => 'LT', 'lv' => 'LV', 'mk' => 'MK', 'nb' => 'NO', 'nl' => 'NL', 'nn' => 'NO', 'no' => 'NO', 'pl' => 'PL', 'pt' => 'PT', 'ro' => 'RO', 'ru' => 'RU', 'sk' => 'SK', 'sl' => 'SI', 'sq' => 'AL', 'sr' => 'RS', 'sv' => 'SE', 'th' => 'TH', 'tr' => 'TR', 'uk' => 'UA', 'vi' => 'VN', 'zh' => 'CN', ); $language = strtolower( isset( $parts[0] ) ? $parts[0] : '' ); return isset( $by_language[ $language ] ) ? $by_language[ $language ] : ''; } /** * Prints the field. * * @param array $field The field settings. * @param int $form_id The form id. * * @return void */ public function render( $field, $form_id ) { if ( ! $this->is_smart( $field ) ) { parent::render( $field, $form_id ); return; } $describedby = $this->describedby( $field, $form_id ); $id = $this->input_id( $field, $form_id ); $name = $this->input_name( $field ); $guess = $this->guess_country( $field ); // After a failed send the visitor's country and number come back. sanitize() // always fills the country in, so an empty one only means a fresh form. $sticky = $this->sticky_value( $field, $form_id ); $country = is_array( $sticky ) && ! empty( $sticky['country'] ) ? $sticky['country'] : $guess; $number = is_array( $sticky ) && isset( $sticky['number'] ) ? (string) $sticky['number'] : ( empty( $field['default_value'] ) ? '' : (string) $field['default_value'] ); ?>
wrapper_data( $field ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped in wrapper_data().?>> render_label( $field, $form_id ); ?>
placeholder="" value="" aria-describedby="" />
render_description( $field, $form_id ); ?>
is_smart( $field ) ) { return parent::sanitize( $value, $field ); } if ( ! is_array( $value ) ) { return array( 'country' => $this->guess_country( $field ), 'number' => sanitize_text_field( is_scalar( $value ) ? (string) $value : '' ), ); } $country = isset( $value['country'] ) && is_scalar( $value['country'] ) ? (string) $value['country'] : ''; $number = isset( $value['number'] ) && is_scalar( $value['number'] ) ? (string) $value['number'] : ''; $country = Ranq_Forms_Phone_Countries::normalize_code( $country ); // A country that did not arrive at all means the picker was removed from the // page before sending. The country the page would have offered anyway then // applies, so validation stays the same. Had this said "unknown", removing // the picker would be a way to loosen the check, which is what we guard against. if ( '' === $country ) { $country = $this->guess_country( $field ); } return array( 'country' => $country, 'number' => sanitize_text_field( $number ), ); } /** * Validation, on the server. * * This is the real check. The one in the browser is only a courtesy: the * field can be removed from the page before sending, so everything that * arrives is measured here again. * * @param mixed $value The cleaned value. * @param array $field The field settings. * * @return string */ public function validate( $value, $field ) { if ( ! $this->is_smart( $field ) ) { return parent::validate( $value, $field ); } $number = is_array( $value ) && isset( $value['number'] ) ? trim( (string) $value['number'] ) : ''; if ( '' === $number ) { return empty( $field['required'] ) ? '' : $this->required_message( $field ); } $code = is_array( $value ) && isset( $value['country'] ) ? (string) $value['country'] : ''; $country = Ranq_Forms_Phone_Countries::get( $code ); $digits = Ranq_Forms_Phone_Countries::national_digits( $number, $country ); if ( '' === $digits ) { return __( 'Enter the phone number in digits.', '2ranq-forms' ); } $length = strlen( $digits ); if ( $length >= (int) $country['min'] && $length <= (int) $country['max'] ) { return ''; } // A country we do not know is named after its own code, so the message is not // empty. Its check is looser, 4 to 15 digits, which is all the worldwide rule // E.164 allows anyway. $name = '' !== $country['name'] ? $country['name'] : Ranq_Forms_Phone_Countries::normalize_code( $code ); if ( (int) $country['min'] === (int) $country['max'] ) { return sprintf( /* translators: 1: country name, 2: number of digits. */ __( 'A phone number in %1$s has %2$d digits.', '2ranq-forms' ), $name, (int) $country['min'] ); } return sprintf( /* translators: 1: country name, 2: smallest number of digits, 3: largest number of digits. */ __( 'A phone number in %1$s has between %2$d and %3$d digits.', '2ranq-forms' ), $name, (int) $country['min'], (int) $country['max'] ); } /** * The value for the entry and for the email. * * It is written in the international form, because that is the only form * that can be dialled from anywhere in the world. * * @param mixed $value The value. * @param array $field The field settings. * * @return string */ public function format_value( $value, $field ) { if ( ! is_array( $value ) ) { return parent::format_value( $value, $field ); } $number = isset( $value['number'] ) ? trim( (string) $value['number'] ) : ''; if ( '' === $number ) { return ''; } $code = isset( $value['country'] ) ? (string) $value['country'] : ''; if ( ! Ranq_Forms_Phone_Countries::exists( $code ) ) { return $number; } $country = Ranq_Forms_Phone_Countries::get( $code ); $digits = Ranq_Forms_Phone_Countries::national_digits( $number, $country ); return '+' . $country['dial'] . ' ' . ( '' === $digits ? $number : $digits ); } }