init(); } /** * Return the singleton instance of this class. * * @since 1.0.0 * @access public * * @return cmplz_tc_document The single instance. */ public static function this() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid return self::$_this; } /** * Return the list of document field names defined in the config. * * Iterates over all wizard fields and collects those whose type is * 'document'. These are the document types the plugin can generate * (e.g. 'terms-conditions'). * * @since 1.0.0 * @access public * * @see cmplz_tc_config::fields() * * @return string[] Array of fieldname strings for document-type fields. */ public function get_document_types() { $fields = COMPLIANZ_TC::$config->fields(); $documents = array(); foreach ( $fields as $fieldname => $field ) { if ( isset( $field['type'] ) && 'document' === $field['type'] ) { $documents[] = $fieldname; } } return $documents; } /** * Check whether a page type is flagged as public in the config. * * Looks up the page definition under COMPLIANZ_TC::$config->pages and * returns true only when a 'public' key is set and truthy. Used to * filter out non-public page types when building the required-pages list. * * @since 1.0.0 * @access public * * @param string $type Page type identifier, e.g. 'terms-conditions'. * @param string $region Region key, e.g. 'all', 'eu', 'us'. * @return bool True if the page is marked public, false otherwise. */ public function is_public_page( $type, $region ) { if ( ! isset( COMPLIANZ_TC::$config->pages[ $region ][ $type ] ) ) { return false; } if ( isset( COMPLIANZ_TC::$config->pages[ $region ][ $type ]['public'] ) && COMPLIANZ_TC::$config->pages[ $region ][ $type ]['public'] ) { return true; } return false; } /** * Determine whether a page type is required for the current wizard answers. * * A page is considered required when it is public AND all of its conditions * are satisfied. Conditions use an AND logic: every question/answer pair in * the 'condition' array must match. Supports 'NOT ' negation. * When no condition is set, the page is assumed required by default. * * @since 1.0.0 * @access public * * @see cmplz_tc_document::is_public_page() * * @param array|string $page Either the page config array or a page type * string to look up in COMPLIANZ_TC::$config->pages. * @param string $region Region key used to look up the page when $page * is passed as a string, e.g. 'all'. * @return bool True if the page is required, false otherwise. */ public function page_required( $page, $region ) { if ( ! is_array( $page ) ) { if ( ! isset( COMPLIANZ_TC::$config->pages[ $region ][ $page ] ) ) { return false; } $page = COMPLIANZ_TC::$config->pages[ $region ][ $page ]; } // If it's not public, it's not required. if ( isset( $page['public'] ) && false === $page['public'] ) { return false; } // If there's no condition, we set it as required. if ( ! isset( $page['condition'] ) ) { return true; } if ( isset( $page['condition'] ) ) { $conditions = $page['condition']; $condition_met = true; $invert = false; foreach ( $conditions as $condition_question => $condition_answer ) { $value = cmplz_tc_get_value( $condition_question, false, $use_default = false ); $invert = false; if ( ! is_array( $condition_answer ) && strpos( $condition_answer, 'NOT ' ) !== false ) { $condition_answer = str_replace( 'NOT ', '', $condition_answer ); $invert = true; } $condition_answer = is_array( $condition_answer ) ? $condition_answer : array( $condition_answer ); foreach ( $condition_answer as $answer_item ) { if ( is_array( $value ) ) { if ( ! isset( $value[ $answer_item ] ) || ! $value[ $answer_item ] ) { $condition_met = false; } else { $condition_met = true; } } else { $condition_met = ( $value === $answer_item ); } // If one condition is met, we break with this condition, so it will return true. if ( $condition_met ) { break; } } // If one condition is not met, we break with this condition, so it will return false. if ( ! $condition_met ) { break; } } $condition_met = $invert ? ! $condition_met : $condition_met; return $condition_met; } return false; } /** * Determine whether a document element should be included in the output. * * Returns true only when both the callback condition and the field-value * condition are satisfied (AND logic). Called for every element during * document HTML generation. * * @since 1.0.0 * @access public * * @see cmplz_tc_document::callback_condition_applies() * @see cmplz_tc_document::condition_applies() * * @param array $element Document element definition array from the config. * @return bool True if the element should be inserted. */ public function insert_element( $element ) { if ( $this->callback_condition_applies( $element ) && $this->condition_applies( $element ) ) { return true; } return false; } /** * Evaluate callback-based conditions for a document element. * * Checks the optional 'callback_condition' key of an element. Each entry * can be a bare function name or prefixed with 'NOT ' for negation. All * callbacks must exist and return a truthy value for the method to return * true. If the element has no callback conditions, returns true immediately. * * @since 1.0.0 * @access public * * @see cmplz_tc_document::insert_element() * * @param array $element Document element definition array from the config. * May contain 'callback_condition' as a string or * array of callable names. * @return bool True if all callback conditions pass (or none exist). */ public function callback_condition_applies( $element ) { if ( isset( $element['callback_condition'] ) ) { $conditions = is_array( $element['callback_condition'] ) ? $element['callback_condition'] : array( $element['callback_condition'] ); foreach ( $conditions as $func ) { $invert = false; if ( strpos( $func, 'NOT ' ) !== false ) { $invert = true; $func = str_replace( 'NOT ', '', $func ); } if ( ! function_exists( $func ) ) { break; } $show_field = $func(); if ( $invert ) { $show_field = ! $show_field; } if ( ! $show_field ) { return false; } } } return true; } /** * Evaluate field-value conditions for a document element. * * Checks the optional 'condition' key of an element against the stored * wizard answers. Supports equality, 'NOT EMPTY', '<' (less than), '>' * (greater than), and 'NOT ' negation. Multicheckbox fields are * checked by key presence. All conditions must be met (AND logic); the * special value 'loop' is skipped here and handled by is_loop_element(). * * @since 1.0.0 * @access public * * @see cmplz_tc_document::insert_element() * @see cmplz_tc_document::is_loop_element() * * @param array $element Document element definition array from the config. * May contain 'condition' as an associative array * keyed by field name with expected answer values. * @return bool True if all conditions pass (or none exist). */ public function condition_applies( $element ) { if ( isset( $element['condition'] ) ) { $fields = COMPLIANZ_TC::$config->fields; $condition_met = true; foreach ( $element['condition'] as $question => $condition_answer ) { // Reset every loop. $invert = false; if ( 'loop' === $condition_answer ) { continue; } if ( ! isset( $fields[ $question ]['type'] ) ) { return false; } $type = $fields[ $question ]['type']; $value = cmplz_tc_get_value( $question ); if ( 'NOT EMPTY' !== $condition_answer && false !== strpos( $condition_answer, 'NOT ' ) ) { $condition_answer = str_replace( 'NOT ', '', $condition_answer ); $invert = true; } // Smaller than. if ( strpos( $condition_answer, '<' ) !== false ) { $condition_answer = trim( str_replace( '<', '', $condition_answer ) ); $current_condition_met = $value < $condition_answer; } else // Greater than. if ( strpos( $condition_answer, '>' ) !== false ) { $condition_answer = trim( str_replace( '>', '', $condition_answer ) ); $current_condition_met = $value > $condition_answer; } elseif ( 'NOT EMPTY' === $condition_answer ) { if ( '' === $value ) { $current_condition_met = false; } else { $current_condition_met = true; } } elseif ( 'multicheckbox' === $type ) { if ( ! isset( $value[ $condition_answer ] ) || ! $value[ $condition_answer ] ) { $current_condition_met = false; } else { $current_condition_met = true; } } else { $current_condition_met = $value === $condition_answer; } $current_condition_met = $invert ? ! $current_condition_met : $current_condition_met; $condition_met = $condition_met && $current_condition_met; } return $condition_met; } return true; } /** * Determine whether a document element should loop over a multi-value field. * * Loop elements have a condition entry whose value is the literal string * 'loop'. When detected, get_document_html() iterates over every saved * value of the associated field and renders the element's content once per * entry (e.g. for listing multiple products or data processing purposes). * * @since 1.0.0 * @access public * * @see cmplz_tc_document::get_document_html() * * @param array $element Document element definition array from the config. * @return bool True if the element is a loop element. */ public function is_loop_element( $element ) { if ( isset( $element['condition'] ) ) { foreach ( $element['condition'] as $question => $condition_answer ) { if ( 'loop' === $condition_answer ) { return true; } } } return false; } /** * Build and return the full HTML for a legal document by type. * * Iterates over all document elements defined in the config for the given * type, evaluates conditions, numbers paragraphs and annexes, processes * loop elements, calls any registered callbacks, replaces field placeholders, * wraps the result in a container div, sanitises with wp_kses(), and passes * the output through do_shortcode() in case of nested shortcodes. The final * string is passed through the 'cmplz_tc_document_html' filter before return. * * @since 1.0.0 * @access public * * @see cmplz_tc_document::insert_element() * @see cmplz_tc_document::replace_fields() * @see cmplz_tc_allowed_html() * * @param string $type Document type identifier, e.g. 'terms-conditions'. * @return string Sanitised HTML string for the document, including the * outer wrapper div and generator comment. */ public function get_document_html( $type ) { $elements = COMPLIANZ_TC::$config->pages['all'][ $type ]['document_elements']; $html = ''; $paragraph = 0; $sub_paragraph = 0; $annex = 0; $annex_arr = array(); $paragraph_id_arr = array(); foreach ( $elements as $id => $element ) { // Count paragraphs. if ( $this->insert_element( $element ) || $this->is_loop_element( $element ) ) { if ( isset( $element['title'] ) && ( ! isset( $element['numbering'] ) || $element['numbering'] ) ) { $sub_paragraph = 0; ++$paragraph; $paragraph_id_arr[ $id ]['main'] = $paragraph; } // Count subparagraphs. if ( isset( $element['subtitle'] ) && $paragraph > 0 && ( ! isset( $element['numbering'] ) || $element['numbering'] ) ) { ++$sub_paragraph; $paragraph_id_arr[ $id ]['main'] = $paragraph; $paragraph_id_arr[ $id ]['sub'] = $sub_paragraph; } // Count annexes. if ( isset( $element['annex'] ) ) { ++$annex; $annex_arr[ $id ] = $annex; } } if ( $this->is_loop_element( $element ) && $this->insert_element( $element ) ) { $fieldname = key( $element['condition'] ); $values = cmplz_tc_get_value( $fieldname ); $loop_content = ''; if ( ! empty( $values ) ) { foreach ( $values as $value ) { if ( ! is_array( $value ) ) { $value = array( $value ); } $fieldnames = array_keys( $value ); if ( 1 === count( $fieldnames ) && 'key' === $fieldnames[0] ) { continue; } $loop_section = $element['content']; foreach ( $fieldnames as $c_fieldname ) { $field_value = ( isset( $value[ $c_fieldname ] ) ) ? $value[ $c_fieldname ] : ''; if ( ! empty( $field_value ) && is_array( $field_value ) ) { $field_value = implode( ', ', $field_value ); } $loop_section = str_replace( '[' . $c_fieldname . ']', $field_value, $loop_section ); } $loop_content .= $loop_section; } $html .= $this->wrap_header( $element, $paragraph, $sub_paragraph, $annex ); $html .= $this->wrap_content( $loop_content ); } } elseif ( $this->insert_element( $element ) ) { $html .= $this->wrap_header( $element, $paragraph, $sub_paragraph, $annex ); if ( isset( $element['content'] ) ) { $html .= $this->wrap_content( $element['content'], $element ); } } if ( isset( $element['callback'] ) && function_exists( $element['callback'] ) ) { $func = $element['callback']; $html .= $func(); } } $html = $this->replace_fields( $html, $paragraph_id_arr, $annex_arr ); $comment = apply_filters( 'cmplz_document_comment', "\n" . '' . "\n" ); $html = $comment . '
' . $html . '
'; $html = wp_kses( $html, cmplz_tc_allowed_html() ); // In case we still have an unprocessed shortcode. // This may happen when a shortcode is inserted in combination with gutenberg. $html = do_shortcode( $html ); return apply_filters( 'cmplz_tc_document_html', $html ); } /** * Render the heading HTML for a document element. * * Produces an

for titles or a

for * subtitles. Annex elements get a dedicated class and the translated * "Annex N:" prefix. Paragraph numbers are appended when the element is * a numbered element and a paragraph counter is active. All text content * is escaped with esc_html(). The separator character between the number * and the title is filterable via 'cmplz_tc_index_char'. * * @since 1.0.0 * @access public * * @see cmplz_tc_document::is_numbered_element() * * @param array $element Document element definition array. May contain * 'title', 'subtitle', 'annex', and 'numbering'. * @param int $paragraph Current paragraph counter value. * @param int $sub_paragraph Current sub-paragraph counter value. * @param int $annex Current annex counter value. * @return string HTML heading string, or empty string for * elements with an empty title. */ public function wrap_header( $element, $paragraph, $sub_paragraph, $annex ) { $nr = ''; if ( isset( $element['annex'] ) ) { $nr = __( 'Annex', 'complianz-terms-conditions' ) . ' ' . $annex . ': '; if ( isset( $element['title'] ) ) { return '

' . esc_html( $nr ) . esc_html( $element['title'] ) . '

'; } if ( isset( $element['subtitle'] ) ) { return '

' . esc_html( $nr ) . esc_html( $element['subtitle'] ) . '

'; } } if ( isset( $element['title'] ) ) { if ( empty( $element['title'] ) ) { return ''; } $nr = ''; if ( $paragraph > 0 && $this->is_numbered_element( $element ) ) { $nr = $paragraph; $index_char = apply_filters( 'cmplz_tc_index_char', '.' ); $nr = $nr . $index_char . ' '; } return '

' . esc_html( $nr ) . esc_html( $element['title'] ) . '

'; } if ( isset( $element['subtitle'] ) ) { if ( $paragraph > 0 && $sub_paragraph > 0 && $this->is_numbered_element( $element ) ) { $nr = $paragraph . '.' . $sub_paragraph . ' '; } return '

' . esc_html( $nr ) . esc_html( $element['subtitle'] ) . '

'; } return ''; } /** * Determine whether a document element should receive a paragraph number. * * Returns the value of the 'numbering' key when present, otherwise * defaults to true so that elements without an explicit setting are * automatically numbered. * * @since 1.0.0 * @access public * * @param array $element Document element definition array. * May contain 'numbering' (bool). * @return bool True if the element should be numbered. */ public function is_numbered_element( $element ) { if ( ! isset( $element['numbering'] ) ) { return true; } return $element['numbering']; } /** * Wrap a sub-header string in bold HTML. * * Returns an empty string when $header is empty. The $paragraph and * $subparagraph parameters are accepted for signature compatibility but * are not currently used in the output. * * @since 1.0.0 * @access public * * @param string $header The sub-header text. Escaped with esc_html(). * @param int $paragraph Current paragraph counter (unused). * @param int $subparagraph Current sub-paragraph counter (unused). * @return string 'header
' or empty string. */ public function wrap_sub_header( $header, $paragraph, $subparagraph ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $paragraph and $subparagraph kept for API compatibility. if ( empty( $header ) ) { return ''; } return '' . esc_html( $header ) . '
'; } /** * Wrap document element content in a paragraph tag. * * Applies an optional CSS class from the element config. Returns an * empty string when content is empty. The class attribute value is * escaped with esc_attr(); content itself is already sanitised by * get_document_html() via wp_kses() before final output. * * @since 1.0.0 * @access public * * @param string $content The content string to wrap. * @param array|false $element Document element definition array. When * provided, a 'class' key adds a CSS class * to the

tag. Default: false. * @return string HTML paragraph string or empty string. */ public function wrap_content( $content, $element = false ) { if ( empty( $content ) ) { return ''; } $class = isset( $element['class'] ) ? 'class="' . esc_attr( $element['class'] ) . '"' : ''; return "

" . $content . '

'; } /** * Replace all template placeholders in the document HTML. * * Handles several categories of placeholder substitution in order: * - '[article-N]' → translated "(See paragraph N)" references * - '[annex-N]' → translated "(See annex N)" references * - '[download_pdf_link]', '[domain]', '[site_url]' → site URL tokens * - '[languages]' → formatted communication language string * - '[checked_date]' → localised document update date * - '[withdrawal_form_link]' → URL to the generated withdrawal form PDF * - '[fieldname]' → individual field values via get_plain_text_value() * - '[comma_fieldname]' → comma-separated version of field values * - '[/fieldname]' → closing for URL fields * * @since 1.0.0 * @access private * * @see cmplz_tc_document::get_plain_text_value() * * @param string $html The raw document HTML with placeholders. * @param array $paragraph_id_arr Map of element ID to paragraph number * array, e.g. array( 'id' => array( 'main' => 1 ) ). * @param array $annex_arr Map of element ID to annex number, e.g. * array( 'id' => 1 ). * @return string HTML with all placeholders replaced. */ private function replace_fields( $html, $paragraph_id_arr, $annex_arr ) { // Replace references. foreach ( $paragraph_id_arr as $id => $paragraph ) { $html = str_replace( "[article-$id]", sprintf( // translators: %s is the paragraph number. __( '(See paragraph %s)', 'complianz-terms-conditions' ), esc_html( $paragraph['main'] ) ), $html ); } foreach ( $annex_arr as $id => $annex ) { $html = str_replace( "[annex-$id]", sprintf( // translators: %s is the annex number. __( '(See annex %s)', 'complianz-terms-conditions' ), esc_html( $annex ) ), $html ); } $html = str_replace( array( '[download_pdf_link]', '[domain]', '[site_url]' ), array( cmplz_tc_url . 'download.php', '' . esc_url_raw( get_home_url() ) . '', site_url() ), $html ); $single_language = cmplz_tc_get_value( 'language_communication' ); if ( 'yes' === $single_language ) { $lang = defined( 'WPLANG' ) ? WPLANG : get_option( 'WPLANG' ); if ( ! $lang ) { $lang = 'en_US'; // Ensures a fallback. } $languages = COMPLIANZ_TC::$config->format_code_lang( $lang ); } else { $languages = cmplz_tc_get_value( 'multilanguage_communication' ); $languages = array_filter( $languages, static function ( $v, $_k ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $_k is required by ARRAY_FILTER_USE_BOTH signature. return '1' === $v; }, ARRAY_FILTER_USE_BOTH ); $languages = array_keys( $languages ); foreach ( $languages as $key => $language ) { $languages[ $key ] = COMPLIANZ_TC::$config->format_code_lang( $language ); } $nr = count( $languages ); $languages = implode( ', ', $languages ); if ( $nr > 1 ) { $last_comma_pos = strrpos( $languages, ',' ); $languages = substr( $languages, 0, $last_comma_pos ) . ' ' . __( 'and', 'complianz-terms-conditions' ) . ' ' . substr( $languages, $last_comma_pos + 1 ); } } $html = str_replace( '[languages]', $languages, $html ); $checked_date = gmdate( get_option( 'date_format' ), get_option( 'cmplz_tc_documents_update_date', get_option( 'cmplz_documents_update_date' ) ) ); $checked_date = cmplz_tc_localize_date( $checked_date ); $html = str_replace( '[checked_date]', esc_html( $checked_date ), $html ); $uploads = wp_upload_dir(); $uploads_url = $uploads['baseurl']; $locale = substr( get_locale(), 0, 2 ); $with_drawal_form_link = $uploads_url . "/complianz/withdrawal-forms/withdrawal-form-$locale.pdf"; $html = str_replace( '[withdrawal_form_link]', $with_drawal_form_link, $html ); // Replace all fields. foreach ( COMPLIANZ_TC::$config->fields() as $fieldname => $field ) { if ( strpos( $html, "[$fieldname]" ) !== false ) { $html = str_replace( "[$fieldname]", $this->get_plain_text_value( $fieldname, true ), $html ); // When there's a closing shortcode it's always a link. $html = str_replace( "[/$fieldname]", '', $html ); } if ( strpos( $html, "[comma_$fieldname]" ) !== false ) { $html = str_replace( "[comma_$fieldname]", $this->get_plain_text_value( $fieldname, false ), $html ); } } return $html; } /** * Resolve a field's stored value to a human-readable string for document output. * * The rendering strategy depends on the field type: * - 'url' → wrapped in an opening tag (closed by [/fieldname]) * - 'email' → passed through the 'cmplz_tc_document_email' filter (obfuscation) * - 'radio' → label looked up from the field's options array * - 'textarea' → newlines converted to
with nl2br() * - array (checkbox) → active keys mapped to labels; rendered as