templates = $templates; } /** * Registers the hooks. */ public function register_hooks() { add_action( 'init', array( $this, 'register_assets' ) ); add_filter( 'style_loader_tag', array( $this, 'async_style' ), 10, 2 ); } /** * Loads the form stylesheet without holding the page: the form is often * far down the page, and the script shows it once the stylesheet applies. * * @param string $tag Link tag. * @param string $handle Style handle. * @return string */ public function async_style( $tag, $handle ) { if ( 'primiko-form' !== $handle || false !== strpos( $tag, 'onload=' ) ) { return $tag; } return preg_replace( "/media=(['\"])all\\1/", 'media="print" onload="this.media=\'all\'"', $tag, 1 ); } /** * Registers the visitor script and style. They are only enqueued on pages * that show a form. */ public function register_assets() { // The minified build of assets/form.js, versioned by its content. $asset = file_exists( PRIMIKO_DIR . 'build/form/form.asset.php' ) ? require PRIMIKO_DIR . 'build/form/form.asset.php' : array( 'version' => PRIMIKO_VERSION ); wp_register_script( 'primiko-form', PRIMIKO_URL . 'build/form/form.js', array(), $asset['version'], array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_register_style( 'primiko-form', PRIMIKO_URL . 'assets/form.css', array(), PRIMIKO_VERSION . '-' . filemtime( PRIMIKO_DIR . 'assets/form.css' ) ); } /** * Returns the HTML of a form. * * @param int $form_id Form post ID. * @param int $heading Heading level of the step titles, 2 to 4. 3 by * default: the form usually sits under the title * of its section. * @return string Empty when the form cannot be shown. */ public function render( $form_id, $heading = 3 ) { $this->heading = max( 2, min( 4, (int) $heading ) ); $form = get_post( $form_id ); $schema = $form && Post_Types::FORM === $form->post_type && 'publish' === $form->post_status ? $this->templates->form_schema( $form->ID ) : null; if ( null === $schema ) { return $this->unavailable_notice(); } ++$this->count; $prefix = 'primiko-' . $this->count . '-'; wp_enqueue_script( 'primiko-form' ); wp_enqueue_style( 'primiko-form' ); Captcha::enqueue(); ob_start(); ?>
' . esc_html__( 'This quote form cannot be shown: it is not published or its trade template is missing. Only editors see this message.', 'primiko' ) . '

'; } /** * Settings read by assets/form.js. * * @param int $form_id Form post ID. * @param array $schema Form schema. * @param string $prefix Element ID prefix. * @return array */ private function config( $form_id, $schema, $prefix ) { $steps = array(); $fields = array(); foreach ( $schema['steps'] as $step ) { $steps[] = array( 'id' => $step['id'], 'title' => $step['title'], 'showIf' => isset( $step['show_if'] ) ? $step['show_if'] : null, 'review' => ! empty( $step['review'] ), ); foreach ( $step['fields'] as $field ) { $entry = array( 'type' => $field['type'], 'step' => $step['id'], 'label' => $field['label'], 'required' => ! empty( $field['required'] ), 'showIf' => isset( $field['show_if'] ) ? $field['show_if'] : null, ); foreach ( array( 'multiple', 'min', 'max', 'unit', 'maxlength' ) as $key ) { if ( isset( $field[ $key ] ) ) { $entry[ $key ] = $field[ $key ]; } } if ( isset( $field['options'] ) ) { $entry['options'] = wp_list_pluck( $field['options'], 'label', 'value' ); } $fields[ $field['id'] ] = $entry; } } return array( 'form' => $form_id, 'prefix' => $prefix, 'heading' => $this->heading, 'tokenUrl' => rest_url( Rest::NAMESPACE . '/token' ), 'submitUrl' => rest_url( Rest::NAMESPACE . '/submit' ), 'steps' => $steps, 'fields' => $fields, 'photoSize' => 2000, 'minFill' => Token::min_fill_time(), 'single' => ! empty( $schema['single'] ), 'either' => ! empty( $schema['contact_either'] ), 'captcha' => Captcha::config(), 'icons' => array( 'close' => self::icon( 'close' ), 'check' => self::icon( 'check' ), ), 'i18n' => array( /* translators: 1: current step number, 2: number of steps, 3: step title. */ 'progress' => __( 'Step %1$d of %2$d: %3$s', 'primiko' ), 'required' => __( 'This field is required.', 'primiko' ), 'requiredOne' => __( 'Please choose an answer.', 'primiko' ), 'email' => __( 'Please enter a valid email address, such as name@example.com.', 'primiko' ), 'phone' => __( 'Please enter a valid phone number.', 'primiko' ), 'number' => __( 'Please enter a number.', 'primiko' ), /* translators: 1: minimum, 2: maximum. */ 'range' => __( 'Please enter a number between %1$s and %2$s.', 'primiko' ), 'address' => __( 'Please fill in the street, postcode and town.', 'primiko' ), 'consent' => __( 'Please tick this box to send your request.', 'primiko' ), 'photosNeeded' => __( 'Please add at least one photo.', 'primiko' ), /* translators: %d: maximum number of photos. */ 'photosMax' => __( 'You can add up to %d photos.', 'primiko' ), 'photoBad' => __( 'Your browser could not read this photo. Please choose a JPEG, PNG or WebP photo. An iPhone photo in HEIC format can be sent from the iPhone itself.', 'primiko' ), /* translators: %d: photo number. */ 'photoRemove' => __( 'Remove photo %d', 'primiko' ), 'photoWorking' => __( 'Preparing the photos...', 'primiko' ), 'checkFields' => __( 'Some answers need your attention. Please check the fields marked below.', 'primiko' ), 'notSpecified' => __( 'Not specified', 'primiko' ), 'yes' => __( 'Yes', 'primiko' ), 'no' => __( 'No', 'primiko' ), 'edit' => __( 'Edit', 'primiko' ), /* translators: %s: step title. */ 'editStep' => __( 'Edit: %s', 'primiko' ), 'sending' => __( 'Sending...', 'primiko' ), 'sendFailed' => __( 'Your request could not be sent. Please check your connection and try again.', 'primiko' ), 'captchaWait' => __( 'Please complete the anti-spam check above the button, then send again.', 'primiko' ), 'contactEither' => __( 'Please give a phone number or an email address.', 'primiko' ), 'photoCounts' => $this->photo_strings( 'count' ), 'photosSummary' => $this->photo_strings( 'summary' ), ), ); } /** * Photo counts with their plural form, one string per number of photos * (0 to the largest a field allows): the script has no plural rules of * its own. * * @param string $kind 'count' for the counter under the picker, 'summary' * for the review step. * @return string[] Strings indexed by number of photos. */ private function photo_strings( $kind ) { $strings = array(); for ( $number = 0; $number <= 10; $number++ ) { $strings[] = 'count' === $kind /* translators: 1: number of photos added, 2: maximum. */ ? _n( '%1$d of %2$d photo', '%1$d of %2$d photos', $number, 'primiko' ) /* translators: %d: number of photos. */ : _n( '%d photo', '%d photos', $number, 'primiko' ); } return $strings; } /** * Prints one step. * * @param array $step Step. * @param string $prefix Element ID prefix. */ private function step( $step, $prefix ) { $id = $prefix . 'step-' . $step['id']; ?>
> label_text( $field ); ?> help( $field, $help ); ?> control( $field, $id, $help ); ?>
control( $field, $id, $help ); ?> help( $field, $help ); ?> control( $field, $id, $help ); ?>
*'; echo ' ' . esc_html__( '(required)', 'primiko' ) . ''; } } /** * Prints the help text. * * @param array $field Field. * @param string $help_id Help element ID, empty when there is none. */ private function help( $field, $help_id ) { if ( '' !== $help_id ) { echo '

' . esc_html( $field['help'] ) . '

'; } } /** * Prints the input of a field. * * @param array $field Field. * @param string $id Element ID. * @param string $help_id Help element ID, empty when there is none. */ private function control( $field, $id, $help_id ) { $describedby = $help_id ? ' aria-describedby="' . esc_attr( $help_id ) . '"' : ''; $required = ! empty( $field['required'] ) ? ' aria-required="true"' : ''; $placeholder = ! empty( $field['placeholder'] ) ? ' placeholder="' . esc_attr( $field['placeholder'] ) . '"' : ''; $name = $field['id']; switch ( $field['type'] ) { case 'textarea': printf( '', esc_attr( $id ), esc_attr( $name ), isset( $field['maxlength'] ) ? (int) $field['maxlength'] : 5000, $describedby, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. $required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static attribute. $placeholder // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. ); break; case 'number': echo ''; printf( '', esc_attr( $id ), esc_attr( $name ), $describedby, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. $required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static attribute. $placeholder // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. ); if ( ! empty( $field['unit'] ) ) { echo ''; } echo ''; break; case 'choice': case 'yesno': $this->options( $field, $id ); break; case 'select': printf( ''; break; case 'address': $this->address( $field, $id ); break; case 'photos': $this->photos( $field, $id, $describedby ); break; case 'consent': $this->consent( $field, $id ); break; default: $types = array( 'email' => 'email', 'tel' => 'tel', 'date' => 'date', ); $type = isset( $types[ $field['type'] ] ) ? $types[ $field['type'] ] : 'text'; printf( '', esc_attr( $type ), esc_attr( $id ), esc_attr( $name ), isset( $field['maxlength'] ) ? (int) $field['maxlength'] : 200, ! empty( $field['autocomplete'] ) ? ' autocomplete="' . esc_attr( $field['autocomplete'] ) . '"' : '', $describedby, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. $required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static attribute. $placeholder // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. ); } } /** * Prints the options of a choice or yes/no field as cards. * * @param array $field Field. * @param string $id Element ID. */ private function options( $field, $id ) { $options = 'yesno' === $field['type'] ? array( array( 'value' => 'yes', 'label' => __( 'Yes', 'primiko' ), ), array( 'value' => 'no', 'label' => __( 'No', 'primiko' ), ), ) : $field['options']; $type = ! empty( $field['multiple'] ) ? 'checkbox' : 'radio'; $layout = 'yesno' === $field['type'] ? ' primiko__options--inline' : ''; // Many short answers fit on two columns when the form is wide enough. if ( '' === $layout && count( $options ) >= 6 && max( array_map( 'mb_strlen', wp_list_pluck( $options, 'label' ) ) ) <= 32 ) { $layout = ' primiko__options--columns'; } echo '
'; foreach ( $options as $index => $option ) { $option_id = $id . '-' . $index; ?> '; } /** * Prints the three inputs of an address. * * @param array $field Field. * @param string $id Element ID. */ private function address( $field, $id ) { $parts = array( 'street' => array( __( 'Street and number', 'primiko' ), 'address-line1', 'text' ), 'postcode' => array( __( 'Postcode', 'primiko' ), 'postal-code', 'numeric' ), 'city' => array( __( 'Town', 'primiko' ), 'address-level2', 'text' ), ); echo '
'; foreach ( $parts as $part => $settings ) { $part_id = $id . '-' . $part; ?>
>
'; } /** * Prints the photo picker. The file input opens the camera or the photo * library on a phone. The script keeps the chosen photos, resized, until * the request is sent. * * @param array $field Field. * @param string $id Element ID. * @param string $describedby Escaped aria-describedby attribute, or empty. */ private function photos( $field, $id, $describedby ) { ?>
>

    ' . esc_html__( 'privacy policy', 'primiko' ) . '', '' . esc_html__( 'terms of service', 'primiko' ) . '' ); ?>

    '', 'close' => '', 'check' => '', ); if ( ! isset( $paths[ $name ] ) ) { return ''; } return ''; } }