- (name-1-first-name, address-1-city). */ class Forminator { const GROUP = 'Forminator'; const EVENT_PREFIX = 'forminator.submitted.'; const OBJECT_TYPE = 'forminator_entry'; /** * Field types that are not template variables: no value (HTML, section, page break), sensitive or binary * (password, file upload), only "checked" (consent, GDPR), a payment (Stripe, PayPal), a captcha, a post * created from the form, a field for add-ons, or a group (its fields are listed on their own). */ const SKIPPED_TYPES = array( 'html', 'section', 'page-break', 'password', 'upload', 'consent', 'gdprcheckbox', 'stripe', 'stripe-ocs', 'paypal', 'captcha', 'postdata', 'custom', 'group', ); /** * Parts of a split name field: stored key => field setting that switches the part on (its label is * _label). Order is the order of the name in a message. */ const NAME_PARTS = array( 'prefix' => 'prefix', 'first-name' => 'fname', 'middle-name' => 'mname', 'last-name' => 'lname', ); /** * Parts of an address field, in the order Forminator stores them (same shape as NAME_PARTS). */ const ADDRESS_PARTS = array( 'street_address' => 'street_address', 'address_line' => 'address_line', 'city' => 'address_city', 'state' => 'address_state', 'zip' => 'address_zip', 'country' => 'address_country', ); /** * Context keys of our own (prefixed so they cannot clash with field variables). */ const KEY_FORM = '_smsportal_form_id'; const KEY_ENTRY = '_smsportal_entry_id'; /** * Whether Forminator is active. * * @return bool */ public static function available(): bool { return defined( 'FORMINATOR_VERSION' ) && class_exists( '\Forminator_API' ); } /** * Register hooks. * * @return void */ public static function register(): void { add_filter( 'smsportal_register_events', array( self::class, 'events' ) ); add_action( 'forminator_after_handle_form', array( self::class, 'submitted' ), 20 ); } /** * Add one event per published form. * * @param mixed $events Events so far. * @return array> */ public static function events( $events ): array { $events = is_array( $events ) ? $events : array(); $forms = \Forminator_API::get_forms( null, 1, -1, 'publish' ); foreach ( is_array( $forms ) ? $forms : array() as $form ) { $events[] = self::event( $form ); } return $events; } /** * Hook: Forminator finished processing a submission (it does not get here for an invalid one). * * @param mixed $entry Forminator_Form_Entry_Model of the submission. * @return void */ public static function submitted( $entry ): void { if ( ! is_object( $entry ) || 'custom-forms' !== ( $entry->entry_type ?? '' ) ) { return; } $form_id = (int) ( $entry->form_id ?? 0 ); $entry_id = (int) ( $entry->entry_id ?? 0 ); // Spam, drafts, abandoned forms and payments still waiting (pending) are not a sent form. A form that does // not store its entries has no entry id; the editor's preview has none either, and sends nothing. if ( $form_id <= 0 || 'active' !== ( $entry->status ?? '' ) || ( $entry_id <= 0 && self::is_preview() ) ) { return; } $event_id = self::EVENT_PREFIX . $form_id; try { $form = \Forminator_API::get_form( $form_id ); if ( ! is_object( $form ) || is_wp_error( $form ) ) { return; } Events::fire( $event_id, self::context( $form, is_array( $entry->meta_data ?? null ) ? $entry->meta_data : array(), $entry_id ) ); } catch ( \Throwable $e ) { Events::report( $e, $event_id ); } } /** * Whether the current request is the form editor's preview (it runs the submission without storing it). * * Runs inside Forminator's forminator_after_handle_form hook, which Forminator fires only after it verified * the form's own nonce (forminator_nonce, in Forminator_Front_Action::validate_ajax()). Forminator passes no * preview flag to the hook, so the AJAX action name is read; it is only compared, nothing is stored or sent. * * @return bool */ private static function is_preview(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, inside Forminator's submission hook after Forminator verified its nonce (see above). $action = isset( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : ''; return 0 === strpos( $action, 'forminator_submit_preview_form_' ); } /** * Event of one form. * * @param object $form Forminator_Form_Model. * @return array */ private static function event( $form ): array { $form_id = (int) $form->id; $labels = array(); foreach ( self::fields( $form ) as $name => $field ) { $labels[ $name ] = $field['label']; } return array( 'id' => self::EVENT_PREFIX . $form_id, /* translators: %s: form title. */ 'label' => sprintf( __( 'Forminator: form "%s" sent', '85pl-android-sms-api' ), self::title( $form ) ), 'group' => self::GROUP, 'icon' => SMSPORTAL_URL_BASE . 'assets/integrations/forminator.png', 'variables' => $labels, 'customer_phone' => static function ( array $context, array $rule = array() ) { $field = (string) ( $rule['phone_field'] ?? '' ); $phone = '' !== $field ? ( $context[ $field ] ?? null ) : null; return is_string( $phone ) && '' !== trim( $phone ) ? $phone : null; }, 'object' => static function ( array $context ) use ( $form_id ): array { $entry_id = (int) ( $context[ self::KEY_ENTRY ] ?? 0 ); return array( 'type' => self::OBJECT_TYPE, // One SMS per stored entry. A form that does not store entries has no id to tell them apart. 'id' => $entry_id > 0 ? $form_id . '-' . $entry_id : '', 'url' => admin_url( 'admin.php?page=forminator-entries&form_type=forminator_forms&form_id=' . $form_id ), ); }, // Entries are stored by Forminator, but a sample would show private data; no preview. 'sample' => null, 'rule_fields' => array( 'phone_field' ), ); } /** * Title of a form as typed in the editor (the model's own name is lower-cased for use as a slug). * * @param object $form Forminator_Form_Model. * @return string */ private static function title( $form ): string { $title = is_array( $form->settings ?? null ) ? (string) ( $form->settings['formName'] ?? '' ) : ''; return '' !== $title ? $title : (string) $form->name; } /** * Fields of a form that become variables, in form order: variable name => label, field slug, type and the * part of the stored value (empty for the whole field). * * @param object $form Forminator_Form_Model. * @return array> */ private static function fields( $form ): array { $fields = array(); foreach ( $form->get_fields() as $model ) { $field = $model->to_formatted_array(); $slug = (string) ( $field['element_id'] ?? '' ); $type = (string) ( $field['type'] ?? '' ); if ( '' === $slug || in_array( $type, self::SKIPPED_TYPES, true ) || isset( $fields[ $slug ] ) ) { continue; } $label = trim( (string) ( $field['field_label'] ?? '' ) ); $label = '' !== $label ? $label : $slug; $fields[ $slug ] = array( 'label' => $label, 'slug' => $slug, 'type' => $type, 'part' => '', ); foreach ( self::parts( $field ) as $part => $part_label ) { $fields[ $slug . '-' . $part ] = array( 'label' => sprintf( '%1$s (%2$s)', $label, $part_label ), 'slug' => $slug, 'type' => $type, 'part' => $part, ); } } return $fields; } /** * Visible parts of a name or address field: stored key => label. A name field is split only when "multiple * names" is on. * * @param array $field Field settings. * @return array */ private static function parts( array $field ): array { $type = (string) ( $field['type'] ?? '' ); if ( 'name' === $type && self::on( $field['multiple_name'] ?? false ) ) { $parts = self::NAME_PARTS; } elseif ( 'address' === $type ) { $parts = self::ADDRESS_PARTS; } else { return array(); } $visible = array(); foreach ( $parts as $part => $setting ) { if ( self::on( $field[ $setting ] ?? false ) ) { $label = trim( (string) ( $field[ $setting . '_label' ] ?? '' ) ); $visible[ $part ] = '' !== $label ? $label : $part; } } return $visible; } /** * A Forminator setting as a boolean ("true" / "false" strings, 1, true). * * @param mixed $value Setting. * @return bool */ private static function on( $value ): bool { return filter_var( $value, FILTER_VALIDATE_BOOLEAN ); } /** * Submitted values as template variables (lists joined with commas). * * @param object $form Forminator_Form_Model. * @param array $meta Stored values of the entry: slug => array( id, value ). * @param int $entry_id Entry id (0 when the form does not store entries). * @return array */ private static function context( $form, array $meta, int $entry_id ): array { $context = array(); foreach ( self::fields( $form ) as $name => $field ) { $value = $meta[ $field['slug'] ]['value'] ?? ''; if ( '' !== $field['part'] ) { $value = is_array( $value ) ? ( $value[ $field['part'] ] ?? '' ) : ''; } $context[ $name ] = self::value( $value, '' === $field['part'] ? $field['type'] : '' ); } $context[ self::KEY_FORM ] = (int) $form->id; $context[ self::KEY_ENTRY ] = $entry_id; return $context; } /** * One value as text: choices are already their labels in the stored entry; a name, address, date or time * made of parts becomes one text; capped in length. * * @param mixed $value Value from the stored entry. * @param string $type Field type, empty for one part of a field. * @return string */ private static function value( $value, string $type ): string { if ( is_array( $value ) && 'date' === $type ) { $text = self::date( $value ); } elseif ( is_array( $value ) && 'time' === $type ) { $text = self::time( $value ); } else { $values = array(); foreach ( is_array( $value ) ? self::flatten( $value ) : array( $value ) as $item ) { $item = is_scalar( $item ) ? trim( (string) $item ) : ''; if ( '' !== $item ) { $values[] = $item; } } $text = implode( 'name' === $type ? ' ' : ', ', $values ); } if ( 'textarea' === $type ) { $text = trim( wp_strip_all_tags( $text ) ); } else { $text = (string) preg_replace( '/\s*\R\s*/', ', ', $text ); } return $text; } /** * A date field made of day, month and year selects, written in the field's own format. * * @param array $value Stored value: day, month, year, format (PHP date format). * @return string */ private static function date( array $value ): string { $day = (int) ( $value['day'] ?? 0 ); $month = (int) ( $value['month'] ?? 0 ); $year = (int) ( $value['year'] ?? 0 ); if ( ! checkdate( $month, $day, $year ) ) { return ''; } $format = isset( $value['format'] ) && is_string( $value['format'] ) && '' !== $value['format'] ? $value['format'] : 'd/m/Y'; return ( new \DateTimeImmutable( sprintf( '%04d-%02d-%02d', $year, $month, $day ) ) )->format( $format ); } /** * A time field made of hours, minutes and (12-hour clock) am/pm. * * @param array $value Stored value: hours, minutes, ampm. * @return string */ private static function time( array $value ): string { if ( ! isset( $value['hours'], $value['minutes'] ) || '' === (string) $value['hours'] ) { return ''; } $text = sprintf( '%1$s:%2$s', $value['hours'], $value['minutes'] ); return ! empty( $value['ampm'] ) ? $text . ' ' . $value['ampm'] : $text; } /** * Values of a nested array, in order. * * @param array $values Values. * @return array */ private static function flatten( array $values ): array { $flat = array(); foreach ( $values as $value ) { if ( is_array( $value ) ) { $flat = array_merge( $flat, self::flatten( $value ) ); } else { $flat[] = $value; } } return $flat; } }