last_section = $this->last_section( $page, $this->step() ); $this->page_url = cmplz_tc_settings_page(); // if a post id was passed, we copy the contents of that page to the wizard settings. if ( isset( $_GET['post_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading post_id from URL for display purposes only; no state change occurs here. $post_id = intval( $_GET['post_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading post_id from URL for display purposes only; no state change occurs here. // get all fields for this page. $fields = COMPLIANZ_TC::$config->fields( $page ); foreach ( $fields as $fieldname => $field ) { $fieldvalue = get_post_meta( $post_id, $fieldname, true ); if ( $fieldvalue ) { // Save single or multiple-value fields appropriately. if ( ! COMPLIANZ_TC::$field->is_multiple_field( $fieldname ) ) { COMPLIANZ_TC::$field->save_field( $fieldname, $fieldvalue ); } else { $field[ $fieldname ] = $fieldvalue; COMPLIANZ_TC::$field->save_multiple( $field ); } } } } } /** * Renders feedback after the wizard's last step is reached. * * If not all required fields are completed, displays a prompt to finish * the remaining questions. Otherwise, shows a success message and the * last-step tip template (e.g. share/download links). * * @since 1.0.0 * @access public * * @return void */ public function last_step_callback() { if ( ! $this->all_required_fields_completed( 'terms-conditions' ) ) { echo '
'; esc_html_e( 'Not all required fields are completed yet. Please check the steps to complete all required questions', 'complianz-terms-conditions' ); echo '
'; } else { echo '
' . esc_html__( "You're done! Here are some tips & tricks to use this document to your full advantage.", 'complianz-terms-conditions' ) . '
'; echo cmplz_tc_get_template( 'wizard/last-step.php' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Template function returns trusted internal HTML. } } /** * Processes completion actions after a wizard step is submitted. * * Fires on the `cmplz_tc_terms-conditions_wizard` action. Clears cached * shortcode transients so the live document reflects any new answers. * When the user clicks Finish or navigates to the last step via Next, * marks the wizard as having been completed at least once. * * @since 1.0.0 * @access public * * @return void */ public function wizard_after_step() { if ( ! cmplz_tc_user_can_manage() ) { return; } // Clear document cache so the rendered document reflects latest answers. COMPLIANZ_TC::$document->clear_shortcode_transients(); // when clicking to the last page, or clicking finish, run the finish sequence. if ( isset( $_POST['cmplz-finish'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified upstream in the form handler. || ( isset( $_POST['step'] ) && 3 === (int) $_POST['step'] // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified upstream in the form handler. && isset( $_POST['cmplz-next'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified upstream in the form handler. ) { $this->set_wizard_completed_once(); } } /** * Runs before a wizard field option is saved. * * Updates the document modification timestamp whenever the wizard is * submitted, regardless of whether any individual field value changed. * Returns early (skips further processing) when the new value is identical * to the previous value. * * @since 1.0.0 * @access public * * @param string $fieldname The name of the field being saved. * @param mixed $fieldvalue The new value submitted for the field. * @param mixed $prev_value The previously stored value for the field. * @param string $type The field type identifier. * @return void */ public function before_save_wizard_option( $fieldname, $fieldvalue, $prev_value, $type ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by the complianz_tc_before_save hook signature. // Stamp the document update time on every save attempt. update_option( 'cmplz_tc_documents_update_date', time() ); // Only run when changes have been made. if ( $fieldvalue === $prev_value ) { return; } } /** * Hook callback invoked after all wizard fields have been saved in a single submission. * * Reserved for future post-save logic that should only execute once all * fields in a submission have been processed (rather than per-field). * Currently a no-op placeholder. * * @since 1.0.0 * @access public * * @param array $posted_fields Associative array of all field names and values * that were included in the current save operation. * @return void */ public function after_saved_all_fields( $posted_fields ) { } /** * Reacts to individual wizard field saves to keep dependent data up to date. * * Currently handles language-related fields: when `language_communication`, * `address_company`, or `multilanguage_communication` changes, the list of * languages for which PDFs should be generated is refreshed. This ensures * withdrawal forms and other locale-specific PDFs are regenerated in the * correct languages after a language change. * * @since 1.0.0 * @access public * * @param string $fieldname The name of the field that was just saved. * @param mixed $fieldvalue The new saved value. * @param mixed $prev_value The value before the save. * @param string $type The field type identifier. * @return void */ public function after_save_wizard_option( $fieldname, $fieldvalue, $prev_value, $type ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by the complianz_tc_after_save hook signature. // Only run when changes have been made. if ( $fieldvalue === $prev_value ) { return; } // if languages have been changed, we update the withdrawal form, if those should be generated. if ( 'language_communication' === $fieldname || 'address_company' === $fieldname || 'multilanguage_communication' === $fieldname ) { $languages = cmplz_tc_get_value( 'multilanguage_communication' ); if ( ! empty( $languages ) ) { // Filter out empty values before storing the active language list. $languages = array_filter( $languages ); update_option( 'cmplz_generate_pdf_languages', $languages ); } } // When only the primary language changes, reset the PDF language list to that single locale. if ( 'language_communication' === $fieldname ) { $languages = array( cmplz_tc_sanitize_language( get_locale() ) ); $languages = array_filter( $languages ); update_option( 'cmplz_generate_pdf_languages', $languages ); } } /** * Returns the next step index that contains at least one visible field. * * Recursively increments the step counter until a step with fields is found * or the last step is reached, whichever comes first. Used to skip over * steps that are entirely empty (e.g. due to unmet conditions). * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index to check first. * @return int The next non-empty step index, or $step if already at the last step. */ public function get_next_not_empty_step( $page, $step ) { if ( ! COMPLIANZ_TC::$field->step_has_fields( $page, $step ) ) { if ( $step >= $this->total_steps( $page ) ) { return $step; } ++$step; // Recurse to check the incremented step. $step = $this->get_next_not_empty_step( $page, $step ); } return $step; } /** * Returns the next section index (within a step) that contains visible fields. * * Sections are keyed with non-sequential integers, so this method resolves the * actual position of the current section within the array before incrementing. * Returns false when all remaining sections in the step are empty. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step containing the sections to search. * @param int $section The section key to start from. * @return int|bool The next non-empty section key, or false if none remain. */ public function get_next_not_empty_section( $page, $step, $section ) { if ( ! COMPLIANZ_TC::$field->step_has_fields( $page, $step, $section ) ) { // some keys are missing, so we need to count the actual number of keys. if ( isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'] ) ) { $n = array_keys( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'] ); // <---- Grab all the keys of your actual array and put in another array $count = array_search( $section, $n, true ); // <--- Returns the position of the offset from this array using search. // this is the actual list up to section key. $new_arr = array_slice( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'], 0, $count + 1, true );// <--- Slice it with the 0 index as start and position+1 as the length parameter. $section_count = count( $new_arr ) + 1; } else { $section_count = $section + 1; } ++$section; if ( $section_count > $this->total_sections( $page, $step ) ) { return false; } $section = $this->get_next_not_empty_section( $page, $step, $section ); } return $section; } /** * Returns the previous step index that contains at least one visible field. * * Recursively decrements the step counter until a non-empty step is found * or step 1 is reached. Used when the user clicks the Previous button to * avoid landing on a step that would show no fields. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index to check first. * @return int The previous non-empty step index, or 1 if already at the first step. */ public function get_previous_not_empty_step( $page, $step ) { if ( ! COMPLIANZ_TC::$field->step_has_fields( $page, $step ) ) { if ( $step <= 1 ) { return $step; } --$step; $step = $this->get_previous_not_empty_step( $page, $step ); } return $step; } /** * Returns the previous section index (within a step) that contains visible fields. * * Recursively decrements the section key until a non-empty section is found. * Returns false when there are no earlier non-empty sections in the current step, * signalling the caller to move to the previous step instead. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step containing the sections to search. * @param int $section The section key to start searching backwards from. * @return false|int The previous non-empty section key, or false if none exist. */ public function get_previous_not_empty_section( $page, $step, $section ) { if ( ! COMPLIANZ_TC::$field->step_has_fields( $page, $step, $section ) ) { --$section; if ( $section < 1 ) { return false; } $section = $this->get_previous_not_empty_section( $page, $step, $section ); } return $section; } /** * Locks the wizard to prevent concurrent edits by other users. * * Stores the current user's ID in a transient whose expiry is controlled by * the `cmplz_wizard_lock_time` filter (default: 2 minutes). The lock is * automatically refreshed each time the wizard page is loaded by the same user. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::wizard_is_locked() * @see cmplz_tc_wizard::get_lock_user() * * @return void */ public function lock_wizard() { $user_id = get_current_user_id(); /** * Filters the wizard lock duration in seconds. * * @since 1.0.0 * * @param int $duration Lock duration in seconds. Default is 2 minutes (120). */ set_transient( 'cmplz_wizard_locked_by_user', $user_id, apply_filters( 'cmplz_wizard_lock_time', 2 * MINUTE_IN_SECONDS ) ); } /** * Checks whether the wizard is currently locked by a different user. * * Compares the ID stored in the lock transient against the currently * logged-in user. Returns true only when there is an active lock held by * someone else; returns false if there is no lock or the lock belongs to * the current user. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::lock_wizard() * @see cmplz_tc_wizard::get_lock_user() * * @return bool True if locked by another user, false otherwise. */ public function wizard_is_locked() { $user_id = get_current_user_id(); $lock_user_id = (int) $this->get_lock_user(); if ( $lock_user_id && $user_id !== $lock_user_id ) { return true; } return false; } /** * Retrieves the ID of the user who holds the current wizard lock. * * Returns the value stored in the `cmplz_wizard_locked_by_user` transient. * Returns false when no lock exists or the transient has expired. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::lock_wizard() * * @return false|int User ID of the locking user, or false if unlocked. */ public function get_lock_user() { return get_transient( 'cmplz_wizard_locked_by_user' ); } /** * Renders the complete wizard UI for a given document page. * * Performs a capability check and, if the wizard is locked by another user, * displays a warning notice and returns early. Otherwise it locks the wizard * for the current user, determines the correct step and section to display * (advancing forward on Next or backward on Previous), then renders the * navigation menu and question content into the admin wrapper template. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param string $wizard_title Optional heading shown above the wizard menu. * Default empty string (no heading rendered). * @return void */ public function wizard( $page, $wizard_title = '' ) { if ( ! cmplz_tc_user_can_manage() ) { return; } if ( $this->wizard_is_locked() ) { // Retrieve lock owner details to include in the warning message. $user_id = $this->get_lock_user(); $user = get_user_by( 'id', $user_id ); /** * Filters the wizard lock duration in seconds. * * @since 1.0.0 * * @param int $duration Lock duration in seconds. Default is 2 minutes (120). */ $lock_time = apply_filters( 'cmplz_wizard_lock_time', 2 * MINUTE_IN_SECONDS ) / 60; cmplz_tc_notice( sprintf( // translators: %s is the display name of the user currently editing the wizard. __( 'The wizard is currently being edited by %s', 'complianz-terms-conditions' ), $user->user_nicename ) . '
' . sprintf( // translators: %s is the number of minutes until the wizard lock expires. __( 'If this user stops editing, the lock will expire after %s minutes.', 'complianz-terms-conditions' ), $lock_time ), 'warning' ); return; } // Lock the wizard for other users while this user is editing. $this->lock_wizard(); $this->initialize( $page ); $section = $this->section(); $step = $this->step(); // Advance to the next non-empty step/section when the current section // has no visible fields or the user clicked Next without validation errors. if ( $this->section_is_empty( $page, $step, $section ) || ( isset( $_POST['cmplz-next'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified upstream; this only controls navigation direction. && ! COMPLIANZ_TC::$field->has_errors() ) ) { if ( COMPLIANZ_TC::$config->has_sections( $page, $step ) && ( $section < $this->last_section ) ) { ++$section; } else { ++$step; $section = $this->first_section( $page, $step ); } $step = $this->get_next_not_empty_step( $page, $step ); $section = $this->get_next_not_empty_section( $page, $step, $section ); // if the last section is also empty, it will return false, so we need to skip the step too. if ( ! $section ) { $step = $this->get_next_not_empty_step( $page, $step + 1 ); $section = 1; } } // Navigate backwards when the user clicked Previous. if ( isset( $_POST['cmplz-previous'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified upstream; this only controls navigation direction. if ( COMPLIANZ_TC::$config->has_sections( $page, $step ) && $section > $this->first_section( $page, $step ) ) { --$section; } else { --$step; $section = $this->last_section( $page, $step ); } $step = $this->get_previous_not_empty_step( $page, $step ); $section = $this->get_previous_not_empty_section( $page, $step, $section ); } $menu = $this->wizard_menu( $page, $wizard_title, $step, $section ); $content = $this->wizard_content( $page, $step, $section ); $args = array( 'page' => 'terms-conditions', 'content' => $menu . $content, ); echo cmplz_tc_get_template( 'admin_wrap.php', $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Template function returns trusted internal HTML. } /** * Renders and returns the wizard navigation menu HTML. * * Iterates over all steps for the given page and builds a step list by * rendering the `wizard/step.php` template for each entry. The active step * also receives its section sub-list. Appends the overall completion * percentage and an optional title block before rendering the outer menu * wrapper via `wizard/menu.php`. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param string $wizard_title Optional heading for the wizard menu. Default empty string. * @param int $active_step The step index currently selected. * @param int $active_section The section index currently selected within the active step. * @return false|string Rendered menu HTML, or false on template failure. */ public function wizard_menu( $page, $wizard_title, $active_step, $active_section ) { $args_menu['steps'] = ''; $total_steps_menu = $this->total_steps( $page ); for ( $i = 1; $i <= $total_steps_menu; $i++ ) { $args['title'] = $i . '. ' . COMPLIANZ_TC::$config->steps[ $page ][ $i ]['title']; $args['active'] = ( $active_step === $i ) ? 'active' : ''; $args['completed'] = $this->required_fields_completed( $page, $i, false ) ? 'complete' : 'incomplete'; $args['url'] = add_query_arg( array( 'step' => $i ), $this->page_url ); if ( $this->post_id() ) { // Preserve the post_id parameter so document origin is maintained during navigation. $args['url'] = add_query_arg( array( 'post_id' => $this->post_id() ), $args['url'] ); } // Only render section links for the currently active step. $args['sections'] = ( 'active' === $args['active'] ) ? $this->wizard_sections( $page, $active_step, $active_section ) : ''; $args_menu['steps'] .= cmplz_tc_get_template( 'wizard/step.php', $args ); } $args_menu['percentage-complete'] = $this->wizard_percentage_complete( false ); $args_menu['title'] = ! empty( $wizard_title ) ? '

' . $wizard_title . '

' : ''; return cmplz_tc_get_template( 'wizard/menu.php', $args_menu ); } /** * Renders and returns the section sub-navigation HTML for a wizard step. * * Iterates over all sections within the given step, skips empty sections, and * builds an HTML string by rendering `wizard/section.php` for each visible * section. The icon changes depending on whether the section is active, * completed, or pending. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step whose sections should be rendered. * @param int $active_section The section index that is currently active. * @return string Rendered sections HTML, or an empty string if the step has no sections. */ public function wizard_sections( $page, $step, $active_section ) { $sections = ''; if ( COMPLIANZ_TC::$config->has_sections( $page, $step ) ) { $first_section_idx = $this->first_section( $page, $step ); $last_section_idx = $this->last_section( $page, $step ); for ( $i = $first_section_idx; $i <= $last_section_idx; $i++ ) { // Default icon: greyed-out circle for pending sections. $icon = cmplz_tc_icon( 'circle', 'disabled', '', 11 ); if ( $this->section_is_empty( $page, $step, $i ) ) { continue; } if ( $i < $this->get_next_not_empty_section( $page, $step, $i ) ) { continue; } $active = ( $active_section === $i ) ? 'active' : ''; if ( 'active' === $active ) { // Chevron icon highlights the current section. $icon = cmplz_tc_icon( 'chevron-right', 'default', '', 11 ); } elseif ( $this->required_fields_completed( $page, $step, $i ) ) { // Checkmark icon indicates all required fields in this section are answered. $icon = cmplz_tc_icon( 'check', 'success', '', 11 ); } $completed = ( $this->required_fields_completed( $page, $step, $i ) ) ? 'cmplz-done' : 'cmplz-to-do'; $url = add_query_arg( array( 'step' => $step, 'section' => $i, ), $this->page_url ); if ( $this->post_id() ) { $url = add_query_arg( array( 'post_id' => $this->post_id() ), $url ); } $title = COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $i ]['title']; $regions = $this->get_section_regions( $page, $step, $i ); // Append region labels (e.g. "EU | US") to the section title when applicable. $title .= $regions ? ' - ' . implode( ' | ', $regions ) : ''; $args = array( 'active' => $active, 'completed' => $completed, 'icon' => $icon, 'url' => $url, 'title' => $title, ); $sections .= cmplz_tc_get_template( 'wizard/section.php', $args ); } } return $sections; } /** * Renders and returns the wizard question area for a specific step and section. * * Builds the full content panel including the section/step title (with region * labels if applicable), navigation buttons (Previous, Next, Save, Finish), a * post-save notice, the intro paragraph, and all visible fields. On the final * step it also appends the other-plugins template and resolves the primary * action button to either "Create" or "Open" the document depending on whether * a shortcode page already exists. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step to render. * @param int $section The section within the step to render. * @return false|string Rendered content HTML, or false on template failure. */ public function wizard_content( $page, $step, $section ) { $args['title'] = ''; if ( isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $section ]['title'] ) ) { $args['title'] = COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $section ]['title']; $regions = $this->get_section_regions( $page, $step, $section ); $args['title'] .= $regions ? ' - ' . implode( ' | ', $regions ) : ''; } else { $args['title'] .= COMPLIANZ_TC::$config->steps[ $page ][ $step ]['title']; } // Initialise all content slots to empty strings. $args['flags'] = ''; $args['save_notice'] = ''; $args['save_as_notice'] = ''; $args['learn_notice'] = ''; $args['cookie_or_finish_button'] = ''; $args['previous_button'] = ''; $args['next_button'] = ''; $args['save_button'] = ''; if ( isset( $_POST['cmplz-save'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified upstream by the field-save handler. $args['save_notice'] = cmplz_tc_notice( __( 'Changes saved', 'complianz-terms-conditions' ), 'success', true, false ); } $args['intro'] = $this->get_intro( $page, $step, $section ); $args['page_url'] = $this->page_url; $args['page'] = $page; // Embed the post_id as a hidden field when editing an existing document post. $args['post_id'] = $this->post_id() ? '' : ''; // Buffer field output so it can be injected into the template variable. ob_start(); COMPLIANZ_TC::$field->get_fields( $page, $step, $section ); $args['fields'] = ob_get_clean(); $args['step'] = $step; $args['section'] = $section; // Render Previous button for any step/section after the first. if ( $step > 1 || $section > 1 ) { $args['previous_button'] = ''; } if ( $step < $this->total_steps( $page ) ) { $args['next_button'] = ''; } $other_plugins = ''; if ( $step > 0 && $step < $this->total_steps( $page ) ) { $args['save_button'] = ''; } elseif ( $step === $this->total_steps( $page ) ) { // On the final step, show an upsell/other-plugins block. $other_plugins = cmplz_tc_get_template( 'wizard/other-plugins.php' ); $page_id = COMPLIANZ_TC::$document->get_shortcode_page_id( 'terms-conditions' ); $link = get_permalink( $page_id ); if ( ! $link ) { // No page exists yet - offer to create one. $link = add_query_arg( array( 'step' => 3 ), cmplz_tc_settings_page() ); // translators: %s is the document type name, e.g. "Terms & Conditions". $args['save_button'] = '' . sprintf( __( 'Create %s', 'complianz-terms-conditions' ), __( 'Terms & Conditions', 'complianz-terms-conditions' ) ) . ''; } else { // Page exists - offer to view it. // translators: %s is the document type name, e.g. "Terms & Conditions". $args['save_button'] = '' . sprintf( __( 'Open %s', 'complianz-terms-conditions' ), __( 'Terms & Conditions', 'complianz-terms-conditions' ) ) . ''; } } return cmplz_tc_get_template( 'wizard/content.php', $args ) . $other_plugins; } /** * Determines whether a wizard section has no visible fields. * * A section is considered empty when the next non-empty section is different * from the supplied section (i.e. all fields in this section are hidden due * to unmet conditions). Empty sections are skipped in the navigation flow * and omitted from the section menu. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step that contains the section. * @param int $section The section key to evaluate. * @return bool True if the section has no visible fields, false otherwise. */ public function section_is_empty( $page, $step, $section ) { $section_compare = $this->get_next_not_empty_section( $page, $step, $section ); if ( $section_compare !== $section ) { return true; } return false; } /** * Enqueues wizard stylesheet on the Terms & Conditions admin pages. * * Hooked to `admin_enqueue_scripts`. Loads a minified CSS file in * production (when SCRIPT_DEBUG is not defined or false) and the * unminified version in debug mode. The stylesheet is only registered and * enqueued when the current admin page slug contains 'terms-conditions'. * * @since 1.0.0 * @access public * * @param string $hook The current admin page hook suffix passed by WordPress. * @return void */ public function enqueue_assets( $hook ) { // Use unminified assets when SCRIPT_DEBUG is enabled. $minified = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; if ( strpos( $hook, 'terms-conditions' ) === false ) { return; } wp_register_style( 'cmplz-tc-terms-conditions', cmplz_tc_url . "assets/css/wizard$minified.css", array(), cmplz_tc_version ); wp_enqueue_style( 'cmplz-tc-terms-conditions' ); } /** * Checks whether all required fields in a step (or section) have values. * * Fetches all fields for the specified page/step/section combination, * filters down to required fields, and for each one verifies that a * non-empty value has been saved. Fields that are gated behind a condition * or callback condition that does not currently apply are skipped. * * When `$section` is false, all required fields across the entire step are * checked rather than a specific section. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index to check. * @param int|false $section The section key to limit the check to, or false * to check the whole step. * @return bool True if all required fields have values, false if any are empty. */ public function required_fields_completed( $page, $step, $section ) { // Get all required fields for this section, and check if they're filled in. $fields = COMPLIANZ_TC::$config->fields( $page, $step, $section ); // Filter the field list down to required fields only. $fields = cmplz_tc_array_filter_multidimensional( $fields, 'required', true ); foreach ( $fields as $fieldname => $args ) { // if a condition exists, only check for this field if the condition applies. if ( isset( $args['condition'] ) || ( isset( $args['callback_condition'] ) && ! COMPLIANZ_TC::$field->condition_applies( $args ) ) ) { continue; } $value = COMPLIANZ_TC::$field->get_value( $fieldname ); if ( empty( $value ) ) { return false; } } return true; } /** * Convenience wrapper for all_required_fields_completed() scoped to the Terms & Conditions wizard. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::all_required_fields_completed() * * @return bool True if all required wizard fields have been answered, false otherwise. */ public function all_required_fields_completed_wizard() { return $this->all_required_fields_completed( 'terms-conditions' ); } /** * Checks whether all required fields across every step (and section) are completed. * * Iterates over every step of the given page. Steps that have sections are * checked section by section; steps without sections are checked as a whole. * Returns false as soon as any required field is found to be empty. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::required_fields_completed() * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @return bool True if every required field has a value, false if any are missing. */ public function all_required_fields_completed( $page ) { $total_steps_all = $this->total_steps( $page ); for ( $step = 1; $step <= $total_steps_all; $step++ ) { if ( COMPLIANZ_TC::$config->has_sections( $page, $step ) ) { $first_section_all = $this->first_section( $page, $step ); $last_section_all = $this->last_section( $page, $step ); for ( $section = $first_section_all; $section <= $last_section_all; $section++ ) { if ( ! $this->required_fields_completed( $page, $step, $section ) ) { return false; } } } elseif ( ! $this->required_fields_completed( $page, $step, false ) ) { return false; } } return true; } /** * Returns the post ID of the document currently being edited. * * Reads `post_id` from `$_GET` or `$_POST` (GET takes precedence) and casts * it to an integer. Returns false when no post ID is present in the request, * indicating the wizard is being used to configure global plugin settings * rather than editing a specific document post. * * @since 1.0.0 * @access public * * @return int|false The integer post ID, or false if not present. */ public function post_id() { $post_id = false; if ( isset( $_GET['post_id'] ) || isset( $_POST['post_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- Reading post_id for display purposes only; no state change performed here. // Prefer GET so direct links (e.g. from the post list) always work. $post_id = ( isset( $_GET['post_id'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading post_id for display purposes only; no state change performed here. ? intval( $_GET['post_id'] ) : intval( $_POST['post_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- Reading post_id for display purposes only; no state change performed here. } return $post_id; } /** * Builds and returns the intro paragraph HTML for a wizard step or section. * * Looks up the `intro` key in the step or section configuration. If found * and non-empty, wraps it in a `
` block. * Section-level intros take precedence over step-level intros when sections * are present. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index. * @param int $section The section key within the step. * @return string HTML intro block, or an empty string if no intro is configured. */ public function get_intro( $page, $step, $section ) { // Only show when in action. $intro = ''; if ( COMPLIANZ_TC::$config->has_sections( $page, $step ) ) { if ( isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $section ]['intro'] ) ) { $intro .= COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $section ]['intro']; } } elseif ( isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['intro'] ) ) { $intro .= COMPLIANZ_TC::$config->steps[ $page ][ $step ]['intro']; } if ( strlen( $intro ) > 0 ) { $intro = '
' . $intro . '
'; } return $intro; } /** * Retrieves the regional identifiers that apply to a wizard step or section. * * Reads the `region` key from the configuration for the given step or section, * normalises it to an array, removes any regions that are not enabled in the * current installation (via `cmplz_has_region()`), and uppercases the remaining * region codes. Returns false when no applicable regions remain. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index. * @param int $section The section key within the step. * @return array|false Array of uppercase region codes (e.g. ['EU', 'US']), * or false if no enabled regions apply. */ public function get_section_regions( $page, $step, $section ) { // Only show when in action. $regions = false; if ( COMPLIANZ_TC::$config->has_sections( $page, $step ) ) { if ( isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $section ]['region'] ) ) { $regions = COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'][ $section ]['region']; } } elseif ( isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['region'] ) ) { $regions = COMPLIANZ_TC::$config->steps[ $page ][ $step ]['region']; } if ( $regions ) { // Normalise scalar region to a single-element array. if ( ! is_array( $regions ) ) { $regions = array( $regions ); } // Remove regions that are not active in this installation. foreach ( $regions as $index => $region ) { if ( ! cmplz_has_region( $region ) ) { unset( $regions[ $index ] ); } } if ( 0 === count( $regions ) ) { $regions = false; } } if ( $regions ) { // Uppercase region codes for consistent display (e.g. 'EU', 'US'). $regions = array_map( 'strtoupper', $regions ); } return $regions; } /** * Resolves the document page type identifier from a post ID or the current request URL. * * When a post ID is supplied, the region and post type are combined to form * the page slug (e.g. 'terms-conditions-eu'). Otherwise the `page` query * parameter is read and the 'cmplz-' prefix is stripped. Returns false when * neither source provides a valid value. * * @since 1.0.0 * @access public * * @param int|false $post_id Optional post ID to derive the page type from. * Default false (falls back to $_GET['page']). * @return string|false The page type slug, or false if it cannot be determined. */ public function get_type( $post_id = false ) { $page = false; if ( $post_id ) { $region = COMPLIANZ_TC::$document->get_region( $post_id ); $post_type = get_post_type( $post_id ); // Combine post type (without 'cmplz-' prefix) and region into the page slug. $page = str_replace( 'cmplz-', '', $post_type ) . '-' . $region; } if ( isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading page slug from URL for routing; no state change performed here. $page = str_replace( 'cmplz-', '', sanitize_title( wp_unslash( $_GET['page'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading page slug from URL for routing; no state change performed here. ); } return $page; } /** * Checks whether the wizard has been completed at least once. * * Reads the `cmplz_wizard_completed_once` option. The option is set to true * by set_wizard_completed_once() when the user finishes the wizard for the * first time. Other parts of the plugin use this to determine whether to show * first-run prompts or assume the document is already configured. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::set_wizard_completed_once() * * @return mixed The stored option value (true when complete, empty string or false otherwise). */ public function wizard_completed_once() { return get_option( 'cmplz_wizard_completed_once' ); } /** * Marks the wizard as having been completed at least once. * * Persists true to the `cmplz_wizard_completed_once` option. Called * automatically when the user clicks Finish or navigates past the last step. * * @since 1.0.0 * @access public * * @see cmplz_tc_wizard::wizard_completed_once() * * @return void */ public function set_wizard_completed_once() { update_option( 'cmplz_wizard_completed_once', true ); } /** * Returns the validated current step index from the request. * * Reads the step from `$_GET` or `$_POST` (POST takes precedence), clamps the * value to the range [1, total_steps]. Defaults to 1 when no step parameter * is present. When `$page` is not supplied, defaults to 'terms-conditions'. * * @since 1.0.0 * @access public * * @param string|false $page The document page identifier to count steps for. * Default false (uses 'terms-conditions'). * @return int The current step index, always within [1, total_steps]. */ public function step( $page = false ) { $step = 1; if ( ! $page ) { $page = 'terms-conditions'; } $total_steps = $this->total_steps( $page ); if ( isset( $_GET['step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading step from URL for navigation; no state change performed here. $step = intval( $_GET['step'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading step from URL for navigation; no state change performed here. } // POST takes precedence over GET (form submission overrides URL parameter). if ( isset( $_POST['step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reading step for navigation only; nonce verified by the form handler upstream. $step = intval( $_POST['step'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reading step for navigation only; nonce verified by the form handler upstream. } // Clamp to valid range. if ( $step > $total_steps ) { $step = $total_steps; } if ( $step <= 1 ) { $step = 1; } return $step; } /** * Returns the validated current section index from the request. * * Reads the section from `$_GET` or `$_POST` (POST takes precedence), clamps * the value to the range [1, last_section]. Defaults to 1 when no section * parameter is present. * * @since 1.0.0 * @access public * * @return int The current section index, always within [1, last_section]. */ public function section() { $section = 1; if ( isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading section from URL for navigation; no state change performed here. $section = intval( $_GET['section'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading section from URL for navigation; no state change performed here. } // POST takes precedence over GET (form submission overrides URL parameter). if ( isset( $_POST['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reading section for navigation only; nonce verified by the form handler upstream. $section = intval( $_POST['section'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reading section for navigation only; nonce verified by the form handler upstream. } if ( $section > $this->last_section ) { $section = $this->last_section; } if ( $section <= 1 ) { $section = 1; } return $section; } /** * Returns the total number of steps configured for a document page. * * Counts the entries in the `steps` configuration array for the given page. * Steps are expected to be keyed sequentially starting at 1. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @return int Total number of steps. */ public function total_steps( $page ) { return count( COMPLIANZ_TC::$config->steps[ $page ] ); } /** * Returns the total number of sections within a wizard step. * * Returns 0 when the step has no sections key in the configuration. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index to count sections for. * @return int Total number of sections, or 0 if the step has no sections. */ public function total_sections( $page, $step ) { if ( ! isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'] ) ) { return 0; } return count( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'] ); } /** * Returns the highest section key for a given step. * * When a step has no sections, returns 1 as a safe default so that navigation * logic can always treat a step as having at least one section. The maximum * key is used rather than the count because section keys may not be sequential. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index to inspect. * @return int The highest section key, or 1 if the step has no sections. */ public function last_section( $page, $step ) { if ( ! isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'] ) ) { return 1; } $array = COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections']; return max( array_keys( $array ) ); } /** * Returns the lowest (first) section key for a given step. * * When a step has no sections, returns 1 as a safe default. Uses key() on * the sections array to get the first key rather than assuming it is 1, * because section keys can be arbitrary integers. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step index to inspect. * @return int The first section key, or 1 if the step has no sections. */ public function first_section( $page, $step ) { if ( ! isset( COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections'] ) ) { return 1; } $arr = COMPLIANZ_TC::$config->steps[ $page ][ $step ]['sections']; $first_key = key( $arr ); return $first_key; } /** * Estimates the remaining time (in minutes) to complete the wizard from a given position. * * Sums the `time` values defined in each field's configuration for all steps * and sections from the current position to the end of the wizard. Sections * are iterated only for the current step (to account for partial completion); * remaining steps are summed in full. The result is rounded up by 0.45 to * avoid showing 0 minutes when a small amount of time remains. * * @since 1.0.0 * @access public * * @param string $page The document page identifier (e.g. 'terms-conditions'). * @param int $step The step the user is currently on. * @param int|false $section The section the user is currently on, or false * if the step has no sections. * @return int Estimated minutes remaining, rounded up. */ public function remaining_time( $page, $step, $section = false ) { // get remaining steps including this one. $time = 0; $total_steps = $this->total_steps( $page ); for ( $i = $total_steps; $i >= $step; $i-- ) { $sub = 0; // if we're on a step with sections, we should add the sections that still need to be done. if ( ( $i === $step ) && COMPLIANZ_TC::$config->has_sections( $page, $step ) ) { for ( $s = $this->last_section( $page, $i ); $s >= $section; $s-- ) { $subsub = 0; $section_fields = COMPLIANZ_TC::$config->fields( $page, $step, $s ); foreach ( $section_fields as $section_fieldname => $section_field ) { if ( isset( $section_field['time'] ) ) { $sub += $section_field['time']; $subsub += $section_field['time']; $time += $section_field['time']; } } } } else { // For steps other than the current one, sum all fields. $fields = COMPLIANZ_TC::$config->fields( $page, $i, false ); foreach ( $fields as $fieldname => $field ) { if ( isset( $field['time'] ) ) { $sub += $field['time']; $time += $field['time']; } } } } return (int) round( $time + 0.45 ); } /** * Calculates the overall wizard completion percentage. * * Counts all required fields across every step and section (excluding fields * whose conditions are not met) and determines how many have a non-empty value. * Also factors in whether the required shortcode pages (e.g. Terms & Conditions * page) have been created. The result is rounded up and cached in * $this->percentage_complete to avoid redundant recalculation on the same request. * * @since 1.0.0 * @access public * * @param bool $count_warnings Whether to include warning-state fields in * the total. Default true (currently unused internally). * @return int Completion percentage between 0 and 100. */ public function wizard_percentage_complete( $count_warnings = true ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter reserved for future use; public API must remain stable. // Return cached value to avoid recalculation within the same request. if ( false !== $this->percentage_complete ) { return $this->percentage_complete; } $total_fields = 0; $completed_fields = 0; $total_steps = $this->total_steps( 'terms-conditions' ); for ( $i = 1; $i <= $total_steps; $i++ ) { $fields = COMPLIANZ_TC::$config->fields( 'terms-conditions', $i, false ); foreach ( $fields as $fieldname => $field ) { // Determine whether this field is required under current conditions. $required = isset( $field['required'] ) ? $field['required'] : false; if ( ( isset( $field['condition'] ) || isset( $field['callback_condition'] ) ) && ! COMPLIANZ_TC::$field->condition_applies( $field ) ) { $required = false; } if ( $required ) { $value = cmplz_tc_get_value( $fieldname, false, false ); ++$total_fields; if ( ! empty( $value ) ) { ++$completed_fields; } } } } // Factor in required document pages (e.g. Terms & Conditions shortcode page). $pages = COMPLIANZ_TC::$document->get_required_pages(); foreach ( $pages as $region => $region_pages ) { foreach ( $region_pages as $type => $page ) { if ( COMPLIANZ_TC::$document->page_exists( $type ) ) { ++$completed_fields; } ++$total_fields; } } // Round up slightly (+ 0.45) so a nearly-complete wizard shows 100% rather than 99%. $percentage = (int) round( 100 * ( $completed_fields / $total_fields ) + 0.45 ); $this->percentage_complete = $percentage; return $percentage; } } } //class closure