diff --git a/projects/packages/forms/changelog/add-forms-submit-timer b/projects/packages/forms/changelog/add-forms-submit-timer new file mode 100644 index 0000000000000..5d9fa9be8c51e --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-submit-timer @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Forms: add form fill duration to form entries diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index 20f1eca85e5f5..041aa392c973d 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -549,6 +549,16 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['form_fill_duration'] = array( + 'description' => __( 'The duration in seconds from first user interaction to form submission. Returns null for submissions before this feature was added.', 'jetpack-forms' ), + 'type' => array( 'integer', 'null' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => 'absint', + ), + 'readonly' => true, + ); + $schema['properties']['browser'] = array( 'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ), 'type' => 'string', @@ -809,6 +819,9 @@ public function prepare_item_for_response( $item, $request ) { if ( rest_is_field_included( 'country_code', $fields ) ) { $data['country_code'] = $feedback_response->get_country_code(); } + if ( rest_is_field_included( 'form_fill_duration', $fields ) ) { + $data['form_fill_duration'] = $feedback_response->get_form_fill_duration(); + } if ( rest_is_field_included( 'browser', $fields ) ) { $data['browser'] = $feedback_response->get_browser(); } diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index d9a4d8e1f81f8..719b8f3beafa2 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -829,6 +829,7 @@ public static function parse( $attributes, $content, $context = array() ) { id='contact-form-$id' class='{$container_classes_string}' data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . " + data-wp-on--focusin=\"callbacks.trackFirstInteraction\" data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\" >\n"; @@ -914,6 +915,7 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label $r .= ''; } $r .= "\n"; + $r .= "\n"; $r .= $form->body; if ( $is_multistep ) { diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index 060e992854ee1..4d7763ed8ffe4 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -90,6 +90,15 @@ class Feedback { */ protected $country_code = null; + /** + * The form fill duration in seconds. + * + * Tracks how long the user spent filling out the form (from first interaction to submission). + * + * @var int|null + */ + protected $form_fill_duration = null; + /** * The subject of the feedback entry. * @@ -234,10 +243,11 @@ private function load_from_post( WP_Post $feedback_post ) { $parsed_content['request_url'] ?? '' ); - $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); - $this->country_code = $parsed_content['country_code'] ?? null; - $this->user_agent = $parsed_content['user_agent'] ?? null; - $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' ); + $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); + $this->country_code = $parsed_content['country_code'] ?? null; + $this->user_agent = $parsed_content['user_agent'] ?? null; + $this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null; + $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' ); $this->notification_recipients = $parsed_content['notification_recipients'] ?? array(); @@ -290,14 +300,15 @@ private function load_from_submission( $post_data, $form, $current_post = null, $this->source = Feedback_Source::from_submission( $current_post, $current_page_number ); // If post_data is provided, use it to populate fields. - $this->fields = $this->get_computed_fields( $post_data, $form ); - $this->ip_address = Contact_Form_Plugin::get_ip_address(); - $this->country_code = $this->get_country_code_from_ip( $this->ip_address ); - $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null; - $this->subject = $this->get_computed_subject( $post_data, $form ); - $this->author_data = Feedback_Author::from_submission( $post_data, $form ); - $this->comment_content = $this->get_computed_comment_content( $post_data, $form ); - $this->has_consent = $this->get_computed_consent( $post_data, $form ); + $this->fields = $this->get_computed_fields( $post_data, $form ); + $this->ip_address = Contact_Form_Plugin::get_ip_address(); + $this->country_code = $this->get_country_code_from_ip( $this->ip_address ); + $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null; + $this->form_fill_duration = isset( $post_data['form_fill_duration'] ) ? absint( $post_data['form_fill_duration'] ) : null; + $this->subject = $this->get_computed_subject( $post_data, $form ); + $this->author_data = Feedback_Author::from_submission( $post_data, $form ); + $this->comment_content = $this->get_computed_comment_content( $post_data, $form ); + $this->has_consent = $this->get_computed_consent( $post_data, $form ); $this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form ); @@ -785,6 +796,17 @@ public function get_country_code() { return $this->country_code; } + /** + * Get the form fill duration in seconds. + * + * Represents the time from first user interaction to form submission. + * + * @return int|null + */ + public function get_form_fill_duration() { + return $this->form_fill_duration; + } + /** * Get the emoji flag for the country. * @@ -1213,6 +1235,7 @@ public function serialize() { 'ip' => $this->ip_address, 'country_code' => $this->country_code, 'user_agent' => $this->user_agent, + 'form_fill_duration' => $this->form_fill_duration, 'notification_recipients' => $this->notification_recipients, ), $this->source->serialize() diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index 30f5976797dc9..2862ada5c42d1 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -466,6 +466,18 @@ const { state, actions } = store( NAMESPACE, { event.stopPropagation(); context.submissionError = null; + // Calculate and set the form fill duration before submission + if ( context.formFirstInteractionTime ) { + const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds + const form = document.getElementById( 'jp-form-' + context.formHash ); + if ( form ) { + const durationField = form.querySelector( 'input[name="form_fill_duration"]' ); + if ( durationField ) { + durationField.value = duration; + } + } + } + const { success, error, data, refreshArgs } = yield submitForm( context.formHash ); if ( success ) { @@ -558,6 +570,14 @@ const { state, actions } = store( NAMESPACE, { registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra ); }, + trackFirstInteraction() { + const context = getContext(); + // Store the first interaction time when user focuses on any form field + if ( ! context.formFirstInteractionTime ) { + context.formFirstInteractionTime = Date.now(); + } + }, + scrollToWrapper() { const context = getContext(); diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php index e429c109913c1..eedd288b03203 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php @@ -360,6 +360,44 @@ public function test_country_code_included_in_serialized_response() { remove_filter( 'jetpack_get_country_from_ip', $filter_callback, 10 ); } + /** + * Test that form_fill_duration is included in serialized response and persists after save/load. + */ + public function test_form_fill_duration_persists_after_save() { + + $form_id = Utility::get_form_id(); + // Create a form submission with form_fill_duration + $_post_data = Utility::get_post_request( + array( + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + 'form_fill_duration' => '45', // 45 seconds + ), + 'g' . $form_id + ); + + $form = new Contact_Form( + array( + 'title' => 'Test Form', + 'description' => 'This is a test form.', + ), + "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]" + ); + + // Create a contact form response + $response = Feedback::from_submission( $_post_data, $form ); + $feedback_post_id = $response->save(); + $saved_response = Feedback::get( $feedback_post_id ); + + // The form_fill_duration should be present and match the test value. + $this->assertNotEmpty( $response->get_form_fill_duration(), 'Form fill duration should not be empty' ); + $this->assertNotEmpty( $saved_response->get_form_fill_duration(), 'Form fill duration should not be empty after save/load' ); + $this->assertEquals( $response->get_form_fill_duration(), $saved_response->get_form_fill_duration(), 'Form fill duration should match after save/load' ); + $this->assertEquals( 45, $saved_response->get_form_fill_duration(), 'Form fill duration should be 45 seconds' ); + $this->assertIsInt( $saved_response->get_form_fill_duration(), 'Form fill duration should be an integer' ); + } + /** * Test the browser information is parsed correctly from user agent. */ diff --git a/projects/packages/forms/tests/php/contact-form/class-utility.php b/projects/packages/forms/tests/php/contact-form/class-utility.php index e757dd8978610..d8dd6cbb229fa 100644 --- a/projects/packages/forms/tests/php/contact-form/class-utility.php +++ b/projects/packages/forms/tests/php/contact-form/class-utility.php @@ -175,7 +175,7 @@ public static function get_post_request( $values, $form_id = null, $post_id = 0 $prefix = $form_id ? $form_id : 'g' . $post_id; $post_data = array(); foreach ( $values as $key => $val ) { - if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 ) { + if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 || strpos( $key, 'form_fill_duration' ) === 0 ) { $post_data[ $key ] = $val; } else { $post_data[ $prefix . '-' . $key ] = $val; diff --git a/projects/plugins/jetpack/changelog/add-forms-submit-timer b/projects/plugins/jetpack/changelog/add-forms-submit-timer new file mode 100644 index 0000000000000..c86ed6d1ca1ec --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-forms-submit-timer @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Forms: Add form fill duration to feedback entries