time of the user's last send. */ const SENT = 'tokenforge_form_sent'; /** Cart item key for the cart action's data. */ const CART = 'tokenforge_form'; /** * Parts switched on. * * @var array */ private $on = array(); /** * Hooks for the parts that are switched on. */ public function __construct() { $s = Components::settings(); $this->on = array( 'limits' => ! empty( $s['form_limits'] ), 'actions' => ! empty( $s['form_actions'] ), 'cart' => ! empty( $s['form_actions'] ) && class_exists( 'WooCommerce' ), ); if ( ! $this->on['limits'] && ! $this->on['actions'] ) { return; } add_filter( 'bricks/elements/form/control_groups', array( $this, 'control_groups' ) ); add_filter( 'bricks/elements/form/controls', array( $this, 'controls' ), 12 ); add_filter( 'bricks/element/render_attributes', array( $this, 'attributes' ), 10, 3 ); if ( $this->on['limits'] ) { add_filter( 'bricks/frontend/render_element', array( $this, 'closed_form' ), 10, 2 ); add_filter( 'bricks/form/validate', array( $this, 'validate' ), 12, 2 ); add_filter( 'bricks/form/response', array( $this, 'sent' ), 10, 2 ); add_filter( 'bricks/dynamic_tags_list', array( $this, 'tags' ) ); add_filter( 'bricks/dynamic_data/render_tag', array( $this, 'render_tag' ), 10, 3 ); add_filter( 'bricks/dynamic_data/render_content', array( $this, 'render_content' ), 10, 3 ); } if ( $this->on['actions'] ) { add_action( 'bricks/form/action/tf-meta', array( $this, 'run_meta' ) ); } if ( $this->on['cart'] ) { add_action( 'bricks/form/action/tf-cart', array( $this, 'run_cart' ) ); add_action( 'woocommerce_before_calculate_totals', array( __CLASS__, 'cart_prices' ), 20 ); add_filter( 'woocommerce_get_cart_item_from_session', array( __CLASS__, 'session_price' ), 20, 2 ); add_filter( 'woocommerce_get_item_data', array( __CLASS__, 'cart_item_data' ), 10, 2 ); add_action( 'woocommerce_checkout_create_order_line_item', array( __CLASS__, 'order_item_meta' ), 10, 3 ); } } // ------------------------------------------------------------------ // Builder settings // ------------------------------------------------------------------ /** * Control groups. * * @param array $groups Groups. * @return array */ public function control_groups( $groups ) { if ( $this->on['limits'] ) { $groups['tfLimits'] = array( 'title' => esc_html__( 'Limits and sending (TokenForge)', 'tokenforge' ), 'tab' => 'content', ); } if ( $this->on['actions'] ) { $groups['tfMeta'] = array( 'title' => esc_html__( 'Update meta (TokenForge)', 'tokenforge' ), 'tab' => 'content', 'required' => array( 'actions', '=', 'tf-meta' ), ); } if ( $this->on['cart'] ) { $groups['tfCart'] = array( 'title' => esc_html__( 'Add to cart (TokenForge)', 'tokenforge' ), 'tab' => 'content', 'required' => array( 'actions', '=', 'tf-cart' ), ); } return $groups; } /** * Settings for limits and the two actions. * * @param array $controls Controls. * @return array */ public function controls( $controls ) { if ( $this->on['limits'] ) { $controls += array( 'tfMax' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Close after this many sends', 'tokenforge' ), 'type' => 'number', 'min' => 1, 'placeholder' => esc_html__( 'No limit', 'tokenforge' ), 'description' => esc_html__( 'With Save submission among the actions, the saved submissions count (deleting one opens a place again); otherwise TokenForge counts the sends. Show places left with {tf_form_left:form ID}.', 'tokenforge' ), ), 'tfClosedText' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Message when closed', 'tokenforge' ), 'type' => 'text', 'placeholder' => esc_html__( 'This form is closed.', 'tokenforge' ), 'required' => array( 'tfMax', '!=', '' ), ), 'tfLoggedIn' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Only signed-in visitors can send', 'tokenforge' ), 'type' => 'checkbox', ), 'tfLoginText' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Message for visitors not signed in', 'tokenforge' ), 'type' => 'text', 'placeholder' => esc_html__( 'Please sign in to use this form.', 'tokenforge' ), 'required' => array( 'tfLoggedIn', '=', true ), ), 'tfOnce' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Sends per user', 'tokenforge' ), 'type' => 'select', 'options' => array( 'once' => esc_html__( 'Once', 'tokenforge' ), 'days' => esc_html__( 'Once every few days', 'tokenforge' ), ), 'placeholder' => esc_html__( 'No limit', 'tokenforge' ), 'description' => esc_html__( 'Counts signed-in users only, so it also switches on "Only signed-in visitors can send".', 'tokenforge' ), ), 'tfOnceDays' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Days between sends', 'tokenforge' ), 'type' => 'number', 'min' => 1, 'placeholder' => '30', 'required' => array( 'tfOnce', '=', 'days' ), ), 'tfSentText' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Message after a user\'s send', 'tokenforge' ), 'type' => 'text', 'placeholder' => esc_html__( 'You have already sent this form.', 'tokenforge' ), 'required' => array( 'tfOnce', '!=', '' ), ), 'tfSubmitRules' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Send button works only when (one per line)', 'tokenforge' ), 'type' => 'textarea', 'placeholder' => "{Terms} not empty\n{Total} > 0", 'description' => esc_html__( 'Each line: a field in braces (its label or ID), then = != > < >= <= contains, empty or not empty, then the value. Checked again on the server.', 'tokenforge' ), ), 'tfSubmitMatch' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Works when', 'tokenforge' ), 'type' => 'select', 'options' => array( 'all' => esc_html__( 'All conditions hold', 'tokenforge' ), 'any' => esc_html__( 'Any condition holds', 'tokenforge' ), ), 'placeholder' => esc_html__( 'All conditions hold', 'tokenforge' ), 'required' => array( 'tfSubmitRules', '!=', '' ), ), 'tfSubmitMode' => array( 'group' => 'tfLimits', 'label' => esc_html__( 'Until then the button is', 'tokenforge' ), 'type' => 'select', 'options' => array( 'disable' => esc_html__( 'Disabled', 'tokenforge' ), 'hide' => esc_html__( 'Hidden', 'tokenforge' ), ), 'placeholder' => esc_html__( 'Disabled', 'tokenforge' ), 'required' => array( 'tfSubmitRules', '!=', '' ), ), ); } if ( $this->on['actions'] && isset( $controls['actions']['options'] ) && is_array( $controls['actions']['options'] ) ) { $controls['actions']['options']['tf-meta'] = esc_html__( 'Update meta (TokenForge)', 'tokenforge' ); if ( $this->on['cart'] ) { $controls['actions']['options']['tf-cart'] = esc_html__( 'Add to cart (TokenForge)', 'tokenforge' ); } $controls += array( 'tfMetaTarget' => array( 'group' => 'tfMeta', 'label' => esc_html__( 'Write to', 'tokenforge' ), 'type' => 'select', 'options' => array( 'post' => esc_html__( 'A post', 'tokenforge' ), 'user' => esc_html__( 'The signed-in user', 'tokenforge' ), ), 'placeholder' => esc_html__( 'A post', 'tokenforge' ), ), 'tfMetaPost' => array( 'group' => 'tfMeta', 'label' => esc_html__( 'Post ID', 'tokenforge' ), 'type' => 'text', 'placeholder' => esc_html__( 'The post the form is on', 'tokenforge' ), 'description' => esc_html__( 'Dynamic data and {{field ID}} work. Another post than the form\'s own needs a signed-in visitor who may edit it.', 'tokenforge' ), 'required' => array( 'tfMetaTarget', '!=', 'user' ), ), 'tfMetaWho' => array( 'group' => 'tfMeta', 'label' => esc_html__( 'Who may write', 'tokenforge' ), 'type' => 'select', 'options' => array( 'editors' => esc_html__( 'Visitors who may edit that post', 'tokenforge' ), 'anyone' => esc_html__( 'Anyone (only the post the form is on)', 'tokenforge' ), ), 'placeholder' => esc_html__( 'Visitors who may edit that post', 'tokenforge' ), 'description' => esc_html__( 'Anyone: for counters, votes or sign-up lists on the page itself.', 'tokenforge' ), 'required' => array( 'tfMetaTarget', '!=', 'user' ), ), 'tfMetaRows' => array( 'group' => 'tfMeta', 'label' => esc_html__( 'Fields to write', 'tokenforge' ), 'type' => 'repeater', 'titleProperty' => 'key', 'placeholder' => esc_html__( 'Meta field', 'tokenforge' ), 'fields' => array( 'key' => array( 'label' => esc_html__( 'Meta key', 'tokenforge' ), 'type' => 'text', 'placeholder' => 'votes', 'description' => esc_html__( 'An ACF field name works too. Keys starting with _ and role keys are refused.', 'tokenforge' ), ), 'value' => array( 'label' => esc_html__( 'Value', 'tokenforge' ), 'type' => 'text', 'placeholder' => '{{field ID}}', ), 'mode' => array( 'label' => esc_html__( 'How', 'tokenforge' ), 'type' => 'select', 'options' => array( 'replace' => esc_html__( 'Replace', 'tokenforge' ), 'add' => esc_html__( 'Add to the number', 'tokenforge' ), 'subtract' => esc_html__( 'Subtract from the number', 'tokenforge' ), 'append' => esc_html__( 'Add to a list', 'tokenforge' ), 'remove' => esc_html__( 'Remove from a list', 'tokenforge' ), ), 'placeholder' => esc_html__( 'Replace', 'tokenforge' ), ), 'skipEmpty' => array( 'label' => esc_html__( 'Skip when empty', 'tokenforge' ), 'type' => 'checkbox', ), ), ), ); } if ( $this->on['cart'] ) { $controls += array( 'tfCartProduct' => array( 'group' => 'tfCart', 'label' => esc_html__( 'Product or variation ID', 'tokenforge' ), 'type' => 'text', 'placeholder' => '{post_id}', 'description' => esc_html__( 'Dynamic data or {{field ID}} (for example a select filled with products).', 'tokenforge' ), ), 'tfCartQty' => array( 'group' => 'tfCart', 'label' => esc_html__( 'Quantity', 'tokenforge' ), 'type' => 'text', 'placeholder' => '1', ), 'tfCartPrice' => array( 'group' => 'tfCart', 'label' => esc_html__( 'Price from the calculated field', 'tokenforge' ), 'type' => 'text', 'placeholder' => esc_html__( 'Label or ID of a field with Calculate', 'tokenforge' ), 'description' => esc_html__( 'The price per item. Worked out again on the server from the answers, so a visitor cannot change it. Empty: the product\'s own price.', 'tokenforge' ), ), 'tfCartAnswers' => array( 'group' => 'tfCart', 'label' => esc_html__( 'Show the answers in the cart and the order', 'tokenforge' ), 'type' => 'checkbox', ), 'tfCartGo' => array( 'group' => 'tfCart', 'label' => esc_html__( 'Then', 'tokenforge' ), 'type' => 'select', 'options' => array( 'cart' => esc_html__( 'Go to the cart', 'tokenforge' ), 'checkout' => esc_html__( 'Go to the checkout', 'tokenforge' ), ), 'placeholder' => esc_html__( 'Stay on the page', 'tokenforge' ), ), ); } return $controls; } // ------------------------------------------------------------------ // Front end // ------------------------------------------------------------------ /** * Submit rules and the cart flag on the form. * * @param array $attributes All attributes of the element, by key. * @param string $key The key being printed. * @param object $element Element. * @return array */ public function attributes( $attributes, $key, $element ) { if ( '_root' !== $key || ! is_object( $element ) || ! isset( $element->name ) || 'form' !== $element->name ) { return $attributes; } $settings = is_array( $element->settings ) ? $element->settings : array(); $fields = isset( $settings['fields'] ) && is_array( $settings['fields'] ) ? array_values( $settings['fields'] ) : array(); $used = false; if ( $this->on['limits'] && ! empty( $settings['tfSubmitRules'] ) ) { $rules = Form_Extras::browser_rules( $fields, Form_Extras::parse_rules( $settings['tfSubmitRules'] ), self::submit_match( $settings ) ); if ( $rules['rules'] ) { $rules['mode'] = isset( $settings['tfSubmitMode'] ) && 'hide' === $settings['tfSubmitMode'] ? 'hide' : 'disable'; $attributes['_root']['data-tf-submit'] = wp_json_encode( $rules ); $used = true; } } if ( $this->on['cart'] && ! empty( $settings['actions'] ) && in_array( 'tf-cart', (array) $settings['actions'], true ) ) { $attributes['_root']['data-tf-cart'] = '1'; $used = true; } if ( $used ) { $attributes['_root']['data-tf-form'] = '1'; Form_Extras::enqueue(); } return $attributes; } /** * "all" or "any" for the submit rules. * * @param array $settings Form settings. * @return string */ private static function submit_match( $settings ) { return isset( $settings['tfSubmitMatch'] ) && 'any' === $settings['tfSubmitMatch'] ? 'any' : 'all'; } /** * A closed form shows its message instead (never in the builder). * * @param string $html The element's HTML. * @param object $instance The element. * @return string */ public function closed_form( $html, $instance ) { if ( ! is_object( $instance ) || ! isset( $instance->name ) || 'form' !== $instance->name || self::in_builder() ) { return $html; } $settings = is_array( $instance->settings ) ? $instance->settings : array(); $element = isset( $instance->element ) && is_array( $instance->element ) ? $instance->element : array( 'id' => $instance->id ); $msg = self::blocked( $settings, self::element_form_id( $element ) ); if ( '' === $msg ) { return $html; } return '
' . esc_html( $msg ) . '
'; } /** * In the builder or its preview? * * @return bool */ private static function in_builder() { return ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) || ( function_exists( 'bricks_is_builder_call' ) && bricks_is_builder_call() ); } /** * The id a form's submissions are stored under (a global form's own id). * * @param array $element Element data. * @return string */ private static function element_form_id( $element ) { $global = class_exists( '\Bricks\Helpers' ) && method_exists( '\Bricks\Helpers', 'get_global_element' ) ? \Bricks\Helpers::get_global_element( $element, 'global' ) : ''; return (string) ( $global ? $global : ( isset( $element['id'] ) ? $element['id'] : '' ) ); } /** * Why the current visitor cannot send a form ('' when they can). * * @param array $settings Form settings. * @param string $form_id Form id. * @return string */ public static function blocked( $settings, $form_id ) { $max = isset( $settings['tfMax'] ) && is_numeric( $settings['tfMax'] ) ? (int) $settings['tfMax'] : 0; if ( $max > 0 && self::count( $form_id, $settings ) >= $max ) { return self::text( $settings, 'tfClosedText', __( 'This form is closed.', 'tokenforge' ) ); } $once = isset( $settings['tfOnce'] ) && in_array( $settings['tfOnce'], array( 'once', 'days' ), true ) ? $settings['tfOnce'] : ''; if ( ( ! empty( $settings['tfLoggedIn'] ) || $once ) && ! is_user_logged_in() ) { return self::text( $settings, 'tfLoginText', __( 'Please sign in to use this form.', 'tokenforge' ) ); } if ( $once ) { $sent = get_user_meta( get_current_user_id(), self::SENT, true ); $last = is_array( $sent ) && isset( $sent[ $form_id ] ) ? (int) $sent[ $form_id ] : 0; $days = isset( $settings['tfOnceDays'] ) && is_numeric( $settings['tfOnceDays'] ) ? max( 1, (int) $settings['tfOnceDays'] ) : 30; if ( $last && ( 'once' === $once || time() - $last < $days * DAY_IN_SECONDS ) ) { return self::text( $settings, 'tfSentText', __( 'You have already sent this form.', 'tokenforge' ) ); } } return ''; } /** * How many times a form was sent: its saved submissions when it saves them, * else TokenForge's own count. * * @param string $form_id Form id. * @param array|null $settings Form settings (null: unknown). * @return int */ public static function count( $form_id, $settings = null ) { $saves = is_array( $settings ) && ! empty( $settings['actions'] ) && in_array( 'save-submission', (array) $settings['actions'], true ); if ( $saves && class_exists( '\Bricks\Integrations\Form\Submission_Database' ) ) { return (int) \Bricks\Integrations\Form\Submission_Database::get_entries_count( $form_id ); } $all = get_option( self::SENDS, array() ); return is_array( $all ) && isset( $all[ $form_id ] ) ? (int) $all[ $form_id ] : 0; } /** * On submit: refuse a closed form, a visitor who may not send it, or a * send button whose conditions do not hold. * * @param array $errors Validation errors so far. * @param object $form The builder's form handler. * @return array */ public function validate( $errors, $form ) { if ( ! is_object( $form ) || ! method_exists( $form, 'get_settings' ) || ! method_exists( $form, 'get_fields' ) ) { return $errors; } $settings = $form->get_settings(); $msg = self::blocked( $settings, self::submission_id( $form ) ); if ( '' !== $msg ) { $errors[] = $msg; return $errors; } if ( ! empty( $settings['tfSubmitRules'] ) ) { $fields = isset( $settings['fields'] ) && is_array( $settings['fields'] ) ? $settings['fields'] : array(); if ( Form_Extras::parse_rules( $settings['tfSubmitRules'] ) && ! Form_Extras::lines_hold( $fields, (array) $form->get_fields(), $settings['tfSubmitRules'], self::submit_match( $settings ) ) ) { $errors[] = __( 'Please complete the form before sending it.', 'tokenforge' ); } } // The builder calls bricks/form/response (where sends are counted) only when // some action left a result; a silent success makes sure every send counts. if ( ! $errors && method_exists( $form, 'set_result' ) ) { $form->set_result( array( 'action' => 'tf-limits', 'type' => 'success', ) ); } return $errors; } /** * The id a submission is stored and limited under. * * @param object $form The builder's form handler. * @return string */ private static function submission_id( $form ) { return (string) ( method_exists( $form, 'get_submission_id' ) ? $form->get_submission_id() : $form->get_id() ); } /** * After a successful send: count it, and remember it for the user. * * @param array $response The response for the browser. * @param object $form The builder's form handler. * @return array */ public function sent( $response, $form ) { if ( ! is_object( $form ) || ! method_exists( $form, 'get_id' ) ) { return $response; } $id = self::submission_id( $form ); if ( '' === $id ) { return $response; } // ponytail: read-modify-write of one option, two sends in the same instant can count once; an atomic counter table if exact limits under load matter. $all = get_option( self::SENDS, array() ); $all = is_array( $all ) ? $all : array(); $all[ $id ] = ( isset( $all[ $id ] ) ? (int) $all[ $id ] : 0 ) + 1; update_option( self::SENDS, $all, false ); $settings = method_exists( $form, 'get_settings' ) ? $form->get_settings() : array(); $user = get_current_user_id(); if ( $user && ! empty( $settings['tfOnce'] ) ) { $sent = get_user_meta( $user, self::SENT, true ); $sent = is_array( $sent ) ? $sent : array(); $sent[ $id ] = time(); update_user_meta( $user, self::SENT, $sent ); } return $response; } // ------------------------------------------------------------------ // Tags: {tf_form_count:formId} and {tf_form_left:formId} // ------------------------------------------------------------------ /** * The tags in the builder's dynamic data list. * * @param array $tags Tags. * @return array */ public function tags( $tags ) { $tags[] = array( 'name' => '{tf_form_count:form ID}', 'label' => __( 'Form: times sent (put the form\'s element ID after the colon)', 'tokenforge' ), 'group' => 'TokenForge', ); $tags[] = array( 'name' => '{tf_form_left:form ID}', 'label' => __( 'Form: places left before it closes', 'tokenforge' ), 'group' => 'TokenForge', ); return $tags; } /** * One tag on its own. * * @param mixed $tag The tag without braces. * @param mixed $post Post. * @param string $context Context. * @return mixed */ public function render_tag( $tag, $post, $context = 'text' ) { if ( ! is_string( $tag ) || ! preg_match( '/^tf_form_(count|left):([^{}]+)$/', $tag, $m ) ) { return $tag; } return self::tag_value( $m[1], $m[2], $post ); } /** * Tags inside text. * * @param string $content Content. * @param mixed $post Post. * @param string $context Context. * @return string */ public function render_content( $content, $post = null, $context = 'text' ) { if ( ! is_string( $content ) || false === strpos( $content, '{tf_form_' ) ) { return $content; } return preg_replace_callback( '/\{tf_form_(count|left):([^{}]+)\}/', function ( $m ) use ( $post ) { return self::tag_value( $m[1], $m[2], $post ); }, $content ); } /** * A tag's value. "abc123" reads the form on the current post; "abc123:42" on post 42. * * @param string $what count|left. * @param string $arg Form id, optionally ":post id". * @param mixed $post Current post. * @return string */ private static function tag_value( $what, $arg, $post ) { $parts = explode( ':', trim( (string) $arg ) ); $form_id = preg_replace( '/[^a-z0-9]/i', '', $parts[0] ); $post_id = isset( $parts[1] ) ? absint( $parts[1] ) : ( is_object( $post ) && isset( $post->ID ) ? (int) $post->ID : (int) get_the_ID() ); $settings = null; if ( $form_id && $post_id && class_exists( '\Bricks\Helpers' ) ) { $found = \Bricks\Helpers::get_element_settings( $post_id, $form_id ); $settings = is_array( $found ) ? $found : null; } $n = self::count( $form_id, $settings ); if ( 'count' === $what ) { return (string) $n; } $max = is_array( $settings ) && isset( $settings['tfMax'] ) && is_numeric( $settings['tfMax'] ) ? (int) $settings['tfMax'] : 0; return $max > 0 ? (string) max( 0, $max - $n ) : ''; } // ------------------------------------------------------------------ // Action: update meta // ------------------------------------------------------------------ /** * Run "Update meta (TokenForge)". * * @param object $form The builder's form handler. * @return void */ public function run_meta( $form ) { $settings = $form->get_settings(); $rows = isset( $settings['tfMetaRows'] ) && is_array( $settings['tfMetaRows'] ) ? $settings['tfMetaRows'] : array(); $user = isset( $settings['tfMetaTarget'] ) && 'user' === $settings['tfMetaTarget']; if ( $user ) { $id = get_current_user_id(); if ( ! $id ) { self::error( $form, 'tf-meta', __( 'Please sign in first.', 'tokenforge' ) ); return; } } else { $id = self::meta_post( $form, $settings ); if ( ! $id ) { self::error( $form, 'tf-meta', __( 'This form cannot save its answers here.', 'tokenforge' ) ); return; } } foreach ( $rows as $row ) { $key = isset( $row['key'] ) ? trim( wp_strip_all_tags( (string) $row['key'] ) ) : ''; if ( '' === $key || ! self::key_allowed( $key, $user ? 'user' : 'post' ) ) { continue; } $value = isset( $row['value'] ) ? self::render( $form, (string) $row['value'] ) : ''; if ( '' === trim( $value ) && ! empty( $row['skipEmpty'] ) ) { continue; } $mode = isset( $row['mode'] ) && in_array( $row['mode'], array( 'add', 'subtract', 'append', 'remove' ), true ) ? $row['mode'] : 'replace'; $old = $user ? get_user_meta( $id, $key, true ) : get_post_meta( $id, $key, true ); self::write_meta( $user ? 'user' : 'post', $id, $key, self::new_value( $mode, $old, $value ) ); } } /** * The post an "Update meta" action may write to (0 when none). * * @param object $form The builder's form handler. * @param array $settings Form settings. * @return int */ private static function meta_post( $form, $settings ) { $own = self::own_post( $form ); $set = isset( $settings['tfMetaPost'] ) ? trim( self::render( $form, (string) $settings['tfMetaPost'] ) ) : ''; $id = '' !== $set ? absint( $set ) : $own; if ( ! $id || ! get_post( $id ) ) { return 0; } $anyone = isset( $settings['tfMetaWho'] ) && 'anyone' === $settings['tfMetaWho']; if ( $anyone ) { // Anyone writes only to the public page the form is on, never a post they name. return $own && $id === $own && is_post_publicly_viewable( $id ) ? $id : 0; } return current_user_can( 'edit_post', $id ) ? $id : 0; } /** * The post a form verifiably belongs to (0 when unknown): a loop post the * builder signed, or the post whose own content holds the form. The posted * postId alone is not trusted: a form in a template is found for any postId. * * @param object $form The builder's form handler. * @return int */ private static function own_post( $form ) { $loop = method_exists( $form, 'get_verified_post_target' ) ? (int) $form->get_verified_post_target() : 0; if ( $loop ) { return $loop; } $post = (int) $form->get_post_id(); $source = null; if ( property_exists( $form, 'settings_source_id' ) ) { $read = function () { return $this->settings_source_id; // phpcs:ignore WordPress.NamingConventions.ValidVariableName -- the form handler's own property }; $bound = \Closure::bind( $read, $form, get_class( $form ) ); $source = $bound ? $bound() : null; } return $post && (int) $source === $post ? $post : 0; } /** * May a form write this meta key? * * @param string $key Meta key. * @param string $type post|user. * @return bool */ public static function key_allowed( $key, $type ) { if ( ! preg_match( '/^[A-Za-z0-9_\-]{1,190}$/', $key ) || is_protected_meta( $key, $type ) ) { return false; } if ( 'user' === $type ) { global $wpdb; $prefix = $wpdb ? $wpdb->get_blog_prefix() : 'wp_'; if ( 0 === strpos( $key, $prefix ) || 0 === strpos( $key, $wpdb ? $wpdb->base_prefix : 'wp_' ) || preg_match( '/capabilities|user_level|session_tokens|^use_ssl$|^locale$|^rich_editing$|^syntax_highlighting$|^admin_color$|^show_admin_bar|^dismissed_wp_pointers$|^default_password_nag$/', $key ) ) { return false; } } return true; } /** * The value to store. * * @param string $mode replace|add|subtract|append|remove. * @param mixed $old Stored value. * @param string $value New value. * @return mixed */ public static function new_value( $mode, $old, $value ) { switch ( $mode ) { case 'add': case 'subtract': $n = Form_Extras::to_number( is_scalar( $old ) ? (string) $old : '' ); $d = Form_Extras::to_number( $value ); $r = 'add' === $mode ? $n + $d : $n - $d; return (string) ( floor( $r ) === $r ? (int) $r : round( $r, 6 ) ); case 'append': case 'remove': $list = is_array( $old ) ? array_values( $old ) : ( '' === (string) $old ? array() : array( (string) $old ) ); if ( 'append' === $mode ) { if ( '' !== $value && ! in_array( $value, $list, true ) ) { $list[] = $value; } return array_slice( $list, -1000 ); // a list anyone can add to keeps its newest 1000 } return array_values( array_filter( $list, function ( $v ) use ( $value ) { return (string) $v !== $value; } ) ); } return $value; } /** * Write a value (through ACF when it is active, so its field types apply). * * @param string $type post|user. * @param int $id Post or user id. * @param string $key Meta key. * @param mixed $value Value. * @return void */ private static function write_meta( $type, $id, $key, $value ) { if ( function_exists( 'update_field' ) && function_exists( 'acf_get_field' ) && acf_get_field( $key ) ) { update_field( $key, $value, 'user' === $type ? 'user_' . $id : $id ); return; } if ( 'user' === $type ) { update_user_meta( $id, $key, $value ); } else { update_post_meta( $id, $key, $value ); } } /** * Fill {{field ID}} placeholders and dynamic data (the builder's own way). * * @param object $form The builder's form handler. * @param string $text Text. * @return string */ private static function render( $form, $text ) { $out = method_exists( $form, 'render_data' ) ? $form->render_data( $text ) : $text; return is_scalar( $out ) ? wp_strip_all_tags( (string) $out ) : ''; } /** * Report an error the builder's way (it stops the remaining actions). * * @param object $form The builder's form handler. * @param string $action Action name. * @param string $msg Message. * @return void */ private static function error( $form, $action, $msg ) { $form->set_result( array( 'action' => $action, 'type' => 'error', 'message' => $msg, ) ); } // ------------------------------------------------------------------ // Action: add to cart // ------------------------------------------------------------------ /** * Run "Add to cart (TokenForge)". * * @param object $form The builder's form handler. * @return void */ public function run_cart( $form ) { if ( ! function_exists( 'WC' ) || ! function_exists( 'wc_get_product' ) ) { return; } if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } if ( ! WC()->cart ) { self::error( $form, 'tf-cart', __( 'The cart is not available right now.', 'tokenforge' ) ); return; } $settings = $form->get_settings(); $pid = absint( self::render( $form, isset( $settings['tfCartProduct'] ) ? (string) $settings['tfCartProduct'] : '' ) ); $product = $pid ? wc_get_product( $pid ) : null; $parent = $product && $product->is_type( 'variation' ) ? wc_get_product( $product->get_parent_id() ) : $product; if ( ! $product || ! $parent || 'publish' !== $parent->get_status() || ! $product->is_purchasable() ) { self::error( $form, 'tf-cart', __( 'This product cannot be bought right now.', 'tokenforge' ) ); return; } $qty_text = isset( $settings['tfCartQty'] ) ? self::render( $form, (string) $settings['tfCartQty'] ) : ''; $qty = '' === trim( $qty_text ) ? 1 : (int) floor( Form_Extras::to_number( $qty_text ) ); $qty = max( 1, min( 9999, $qty ) ); $data = array(); $fields = isset( $settings['fields'] ) && is_array( $settings['fields'] ) ? array_values( $settings['fields'] ) : array(); $sent = (array) $form->get_fields(); $hidden = Form_Extras::hidden_fields( $fields, $sent ); if ( ! empty( $settings['tfCartPrice'] ) ) { // A form price needs answers the form itself offers: a product picked // from a choice field (not typed), choices from their options, numbers // within their limits. Otherwise any product or price could be posted. if ( preg_match_all( '/\{\{(\w+)\}\}/', isset( $settings['tfCartProduct'] ) ? (string) $settings['tfCartProduct'] : '', $refs ) ) { foreach ( $refs[1] as $ref ) { $pf = Form_Extras::find_field( $fields, $ref ); if ( ! $pf || ! in_array( isset( $pf['type'] ) ? $pf['type'] : '', array( 'select', 'radio' ), true ) ) { self::error( $form, 'tf-cart', __( 'With a form price, the product must come from a select or radio field.', 'tokenforge' ) ); return; } } } if ( ! self::answers_valid( $fields, $sent ) ) { self::error( $form, 'tf-cart', __( 'Please check your answers and try again.', 'tokenforge' ) ); return; } $field = Form_Extras::find_field( $fields, (string) $settings['tfCartPrice'] ); if ( ! $field || ! Form_Extras::has_calc( $field ) ) { self::error( $form, 'tf-cart', __( 'The price field must be a field with Calculate.', 'tokenforge' ) ); return; } // Worked out again from the answers: the browser's number is never used. $calc = Form_Extras::calculate( $fields, $sent, $hidden ); $price = isset( $calc[ $field['id'] ] ) ? Form_Extras::to_number( $calc[ $field['id'] ] ) : 0; if ( $price <= 0 || ! is_finite( $price ) ) { self::error( $form, 'tf-cart', __( 'This price cannot be used.', 'tokenforge' ) ); return; } $data['price'] = round( $price, wc_get_price_decimals() ); } if ( ! empty( $settings['tfCartAnswers'] ) ) { $data['answers'] = self::answers( $fields, $sent, $hidden ); } $variation_id = $product->is_type( 'variation' ) ? $product->get_id() : 0; $variation = $variation_id ? $product->get_variation_attributes() : array(); $product_id = $variation_id ? $product->get_parent_id() : $product->get_id(); $added = WC()->cart->add_to_cart( $product_id, $qty, $variation_id, $variation, $data ? array( self::CART => $data ) : array() ); if ( ! $added ) { $notices = function_exists( 'wc_get_notices' ) ? wc_get_notices( 'error' ) : array(); if ( function_exists( 'wc_clear_notices' ) ) { wc_clear_notices(); } $first = $notices ? reset( $notices ) : null; $text = is_array( $first ) && isset( $first['notice'] ) ? wp_strip_all_tags( $first['notice'] ) : __( 'This product could not be added to the cart.', 'tokenforge' ); self::error( $form, 'tf-cart', $text ); return; } $go = isset( $settings['tfCartGo'] ) ? $settings['tfCartGo'] : ''; if ( in_array( $go, array( 'cart', 'checkout' ), true ) ) { $form->set_result( array( 'action' => 'tf-cart', 'type' => 'redirect', 'redirectTo' => 'cart' === $go ? wc_get_cart_url() : wc_get_checkout_url(), 'redirectRendered' => true, ) ); } } /** * The visitor's answers as label => text (for the cart and the order). * * @param array $fields Field settings. * @param array $sent Submitted values. * @param string[] $hidden Ids of hidden fields. * @return array */ private static function answers( $fields, $sent, $hidden ) { $out = array(); foreach ( $fields as $f ) { $type = isset( $f['type'] ) ? $f['type'] : 'text'; if ( empty( $f['id'] ) || in_array( $f['id'], $hidden, true ) || in_array( $type, array( 'html', 'hidden', 'password', 'file', 'image', 'gallery', 'rememberme' ), true ) ) { continue; } $v = null; foreach ( array( Form_Extras::input_name( $f ), 'form-field-' . $f['id'] ) as $name ) { if ( isset( $sent[ $name ] ) ) { $v = $sent[ $name ]; break; } } // A choice shows its label, not its value ("Large", not "20"). $labels = self::option_labels( $f ); $list = is_array( $v ) ? array_map( 'strval', array_filter( $v, 'is_scalar' ) ) : ( is_scalar( $v ) ? array( (string) $v ) : array() ); $text = implode( ', ', array_map( function ( $one ) use ( $labels ) { return isset( $labels[ $one ] ) ? $labels[ $one ] : $one; }, $list ) ); $text = trim( wp_strip_all_tags( $text ) ); if ( '' === $text ) { continue; } $label = isset( $f['label'] ) && '' !== trim( (string) $f['label'] ) ? wp_strip_all_tags( (string) $f['label'] ) : $f['id']; $out[ $label ] = mb_substr( $text, 0, 500 ); } return array_slice( $out, 0, 30, true ); } /** * Are a submission's answers ones the form offers? Choices must be among their * options, numbers within their min and max. Calculated fields are skipped * (they are worked out again). * * @param array $fields Field settings. * @param array $sent Submitted values. * @return bool */ public static function answers_valid( $fields, $sent ) { foreach ( $fields as $f ) { if ( empty( $f['id'] ) || Form_Extras::has_calc( $f ) ) { continue; } $v = null; foreach ( array( Form_Extras::input_name( $f ), 'form-field-' . $f['id'] ) as $name ) { if ( isset( $sent[ $name ] ) ) { $v = $sent[ $name ]; break; } } $list = array_filter( array_map( 'strval', is_array( $v ) ? array_filter( $v, 'is_scalar' ) : ( is_scalar( $v ) ? array( $v ) : array() ) ), function ( $x ) { return '' !== trim( $x ); } ); if ( ! $list ) { continue; } $type = isset( $f['type'] ) ? $f['type'] : 'text'; $allowed = self::allowed_values( $f ); if ( null !== $allowed ) { foreach ( $list as $one ) { if ( ! in_array( trim( $one ), $allowed, true ) ) { return false; } } } if ( 'number' === $type ) { foreach ( $list as $one ) { if ( ! is_numeric( $one ) ) { return false; } $max = isset( $f['max'] ) && is_numeric( $f['max'] ) ? (float) $f['max'] : ( isset( $f['tfLook'] ) && 'rating' === $f['tfLook'] ? 5.0 : null ); if ( ( isset( $f['min'] ) && is_numeric( $f['min'] ) && (float) $one < (float) $f['min'] ) || ( null !== $max && (float) $one > $max ) ) { return false; } } } } return true; } /** * The values a choice field offers (null when it is not a choice field or has no list). * * @param array $field Field settings. * @return string[]|null */ private static function allowed_values( $field ) { if ( ! in_array( isset( $field['type'] ) ? $field['type'] : '', array( 'select', 'radio', 'checkbox' ), true ) ) { return null; } $out = array(); $typed = ! empty( $field['options'] ) && ( empty( $field['tfOptFrom'] ) || ! empty( $field['tfOptKeep'] ) ) ? preg_split( '/\r\n|\r|\n/', (string) $field['options'] ) : array(); foreach ( $typed as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } // The value the rendered option carries (see Form_Fields::fill_options() and the builder's "value:label"). if ( ! empty( $field['valueLabelOptions'] ) ) { $out[] = false !== strpos( $line, ':' ) ? trim( strstr( $line, ':', true ) ) : $line; } else { $out[] = ! empty( $field['tfOptFrom'] ) ? Form_Fields::option_value( $line ) : $line; } } if ( ! empty( $field['tfOptFrom'] ) ) { foreach ( array_keys( (array) Form_Fields::content_options( $field ) ) as $value ) { $out[] = Form_Fields::option_value( (string) $value ); } } return $out ? $out : null; } /** * A choice field's labels by value (typed value:label lines, or options from content). * * @param array $field Field settings. * @return array */ private static function option_labels( $field ) { if ( ! in_array( isset( $field['type'] ) ? $field['type'] : '', array( 'select', 'radio', 'checkbox' ), true ) ) { return array(); } $out = ! empty( $field['tfOptFrom'] ) ? (array) Form_Fields::content_options( $field ) : array(); if ( ! empty( $field['valueLabelOptions'] ) && ! empty( $field['options'] ) ) { foreach ( preg_split( '/\r\n|\r|\n/', (string) $field['options'] ) as $line ) { $parts = explode( ':', $line, 2 ); if ( 2 === count( $parts ) ) { $out[ trim( $parts[0] ) ] = wp_strip_all_tags( trim( $parts[1] ) ); } } } return $out; } /** * Cart items added by a form keep the form's price. * * @param \WC_Cart $cart Cart. * @return void */ public static function cart_prices( $cart ) { if ( ! is_object( $cart ) || ( is_admin() && ! wp_doing_ajax() ) ) { return; } foreach ( $cart->get_cart() as $item ) { if ( isset( $item[ self::CART ]['price'], $item['data'] ) && is_object( $item['data'] ) ) { $item['data']->set_price( (float) $item[ self::CART ]['price'] ); } } } /** * The form's price also when the cart is read back from the session (the * mini cart shows item prices without working out the totals again). * * @param array $item Cart item. * @param array $values Stored values. * @return array */ public static function session_price( $item, $values ) { if ( isset( $values[ self::CART ]['price'], $item['data'] ) && is_object( $item['data'] ) ) { $item['data']->set_price( (float) $values[ self::CART ]['price'] ); } return $item; } /** * The answers under the item in the cart and the checkout. * * @param array $data Item data rows. * @param array $item Cart item. * @return array */ public static function cart_item_data( $data, $item ) { if ( ! empty( $item[ self::CART ]['answers'] ) && is_array( $item[ self::CART ]['answers'] ) ) { foreach ( $item[ self::CART ]['answers'] as $label => $text ) { $data[] = array( 'key' => (string) $label, 'value' => (string) $text, ); } } return $data; } /** * The answers on the order item. * * @param \WC_Order_Item_Product $order_item Order item. * @param string $key Cart item key. * @param array $item Cart item. * @return void */ public static function order_item_meta( $order_item, $key, $item ) { if ( ! empty( $item[ self::CART ]['answers'] ) && is_array( $item[ self::CART ]['answers'] ) ) { foreach ( $item[ self::CART ]['answers'] as $label => $text ) { $order_item->add_meta_data( (string) $label, (string) $text ); } } } /** * A text setting or its default. * * @param array $settings Settings. * @param string $key Key. * @param string $default Default. * @return string */ private static function text( $settings, $key, $default ) { $v = isset( $settings[ $key ] ) ? trim( wp_strip_all_tags( (string) $settings[ $key ] ) ) : ''; return '' !== $v ? $v : $default; } }