form id. */ const OPTION_INDEX = 'ranq_forms_widget_index'; /** * The first widget number. It starts at a thousand and one so that a widget * number is obviously not a form id at first glance. */ const FIRST_ID = 1001; /** * Hooks up. * * @return void */ public static function hooks() { add_shortcode( '2ranq_widget', array( __CLASS__, 'shortcode' ) ); add_shortcode( 'ranq_widget', array( __CLASS__, 'shortcode' ) ); add_action( 'before_delete_post', array( __CLASS__, 'forget' ) ); } /** * The default settings. No look is chosen. * * @return array */ public static function blank() { return array( 'layout' => 'none', // Who owns the design of the FORM itself: `widget` means the design arrived // with this section, `user` that a person took it over and now changes it in // the Design tab. The two exclude each other. Empty means there is no record, // which is the case for every form saved before this rule; the builder then // works the owner out by comparison, and no saved value is touched. // // It sits here and not next to the form design, because while the section // holds the design it owns it: when the section goes, the right goes with it. 'style_owner' => '', 'heading' => '', 'lead' => '', 'image' => '', 'bg' => '', 'text' => '', 'rows' => array( 'phone', 'mail', 'pin', 'clock' ), // Empty means "take it from the global settings". That is why the global // value is never written in, because a later change to the global one would // then stop applying to this widget. 'phone' => '', 'email' => '', 'street' => '', 'city' => '', 'hours' => '', // The values the pattern itself describes. The keys depend on the pattern, so // they cannot be listed here. Empty means "leave it as the pattern intended". 'values' => array(), ); } /** * The looks on offer, with "none" in first place. * * @return array */ public static function layouts() { $out = array( 'none' => array( 'title' => __( 'None', '2ranq-forms' ), 'description' => __( 'Just the form, nothing around it. This is how it starts.', '2ranq-forms' ), 'group' => 'none', 'purpose' => 'none', 'style' => '', 'parts' => array(), ), ); foreach ( Ranq_Forms_Patterns::all() as $key => $pattern ) { $out[ $key ] = array( 'title' => $pattern['title'], 'description' => $pattern['description'], 'group' => isset( $pattern['group'] ) ? $pattern['group'] : 'plain', 'purpose' => isset( $pattern['purpose'] ) ? $pattern['purpose'] : 'plain', 'style' => isset( $pattern['style'] ) ? $pattern['style'] : '', 'parts' => self::parts_for( $key ), ); } return $out; } /** * The widget settings of one form. * * @param int $form_id The form id. * * @return array */ public static function get( $form_id ) { $saved = get_post_meta( absint( $form_id ), self::META, true ); return self::sanitize( is_array( $saved ) ? $saved : array() ); } /** * Cleans the settings. * * @param array $raw The raw values. * * @return array */ public static function sanitize( $raw ) { $out = self::blank(); if ( ! is_array( $raw ) ) { return $out; } $layouts = self::layouts(); if ( isset( $raw['layout'] ) && isset( $layouts[ $raw['layout'] ] ) ) { $out['layout'] = (string) $raw['layout']; } // The owner of the form design. Only two known words are accepted, anything // else means "no record" and the builder then works it out itself. if ( isset( $raw['style_owner'] ) && in_array( $raw['style_owner'], array( 'user', 'widget' ), true ) ) { $out['style_owner'] = (string) $raw['style_owner']; } foreach ( array( 'heading', 'lead', 'phone', 'street', 'city', 'hours' ) as $key ) { if ( isset( $raw[ $key ] ) && is_scalar( $raw[ $key ] ) ) { $out[ $key ] = sanitize_text_field( (string) $raw[ $key ] ); } } if ( isset( $raw['image'] ) && is_scalar( $raw['image'] ) ) { $out['image'] = esc_url_raw( (string) $raw['image'] ); } // The email is stored as PLAIN TEXT, not through `sanitize_email`. While a // person is typing, "milos@" is not yet a valid address and `sanitize_email` // would turn it into an empty string, so the preview would fall back to the // global value and it would look as if typing did nothing. Validity is checked // where it matters: the link is built only when the address is whole. if ( isset( $raw['email'] ) && is_scalar( $raw['email'] ) ) { $out['email'] = sanitize_text_field( (string) $raw['email'] ); } foreach ( array( 'bg', 'text' ) as $key ) { if ( ! isset( $raw[ $key ] ) || ! is_scalar( $raw[ $key ] ) ) { continue; } $value = trim( (string) $raw[ $key ] ); // Empty means "do not touch the colour the section already has". if ( '' === $value ) { continue; } $out[ $key ] = self::color( $value ); } // The values are cleaned by the description the pattern itself gave. What the // pattern did not list does not get in, so nothing can be smuggled through. if ( isset( $raw['values'] ) && is_array( $raw['values'] ) ) { $opis = self::controls_for( isset( $raw['layout'] ) ? $raw['layout'] : $out['layout'] ); foreach ( $raw['values'] as $kljuc => $vrednost ) { if ( ! isset( $opis[ $kljuc ] ) || ! is_scalar( $vrednost ) ) { continue; } $vrednost = (string) $vrednost; if ( '' === trim( $vrednost ) ) { continue; } switch ( $opis[ $kljuc ]['type'] ) { case 'color': $cista = self::color( $vrednost ); if ( $cista ) { $out['values'][ $kljuc ] = $cista; } break; case 'size': $out['values'][ $kljuc ] = (string) absint( $vrednost ); break; case 'select': // Only what the pattern really offers. The value goes into the // style on the section root, so anything at all must not be accepted. $moguce = isset( $opis[ $kljuc ]['options'] ) ? array_keys( (array) $opis[ $kljuc ]['options'] ) : array(); if ( in_array( $vrednost, $moguce, true ) ) { $out['values'][ $kljuc ] = $vrednost; } break; case 'image': $cista = esc_url_raw( $vrednost ); if ( $cista ) { $out['values'][ $kljuc ] = $cista; } break; default: $out['values'][ $kljuc ] = sanitize_text_field( $vrednost ); break; } } } if ( isset( $raw['rows'] ) && is_array( $raw['rows'] ) ) { $allowed = array( 'phone', 'mail', 'pin', 'clock' ); $rows = array(); foreach ( $raw['rows'] as $row ) { if ( in_array( $row, $allowed, true ) ) { $rows[] = (string) $row; } } $out['rows'] = $rows; } return $out; } /** * The settings description of one pattern, as a flat list. * * @param string $layout The layout key. * * @return array */ public static function controls_for( $layout ) { $out = array(); foreach ( Ranq_Forms_Business::all() as $b ) { if ( $b['key'] !== $layout || empty( $b['parts'] ) ) { continue; } foreach ( $b['parts'] as $part ) { foreach ( $part['controls'] as $kljuc => $opis ) { $out[ $kljuc ] = $opis; } } } return $out; } /** * The parts of a pattern, for the builder. * * @param string $layout The layout key. * * @return array */ public static function parts_for( $layout ) { foreach ( Ranq_Forms_Business::all() as $b ) { if ( $b['key'] === $layout && ! empty( $b['parts'] ) ) { return $b['parts']; } } return array(); } /** * A colour, or an empty string when it is not a colour. * * @param string $value The value. * * @return string */ private static function color( $value ) { if ( preg_match( '/^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/', $value ) ) { return strtolower( $value ); } if ( preg_match( '/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*[\d.]+\s*)?\)$/i', $value ) ) { return $value; } return ''; } /** * Saves the settings and, when needed, hands out a widget number. * * @param int $form_id The form id. * @param array $raw The raw settings. * * @return array The cleaned settings, with the widget number under the key `widgetId`. */ public static function save( $form_id, $raw ) { $form_id = absint( $form_id ); $clean = self::sanitize( $raw ); update_post_meta( $form_id, self::META, $clean ); // The number is handed out only once a look has been chosen, so forms that do // not use a widget do not burn numbers. $widget_id = 'none' === $clean['layout'] ? self::existing_id( $form_id ) : self::ensure_id( $form_id ); $clean['widgetId'] = $widget_id; return $clean; } /** * The widget number when one is already handed out, otherwise zero. * * @param int $form_id The form id. * * @return int */ public static function existing_id( $form_id ) { return absint( get_post_meta( absint( $form_id ), self::META_ID, true ) ); } /** * The widget number, handed out when there is none yet. * * @param int $form_id The form id. * * @return int */ public static function ensure_id( $form_id ) { $form_id = absint( $form_id ); $id = self::existing_id( $form_id ); if ( $id ) { return $id; } $next = absint( get_option( self::OPTION_NEXT, 0 ) ); $id = $next >= self::FIRST_ID ? $next : self::FIRST_ID; update_option( self::OPTION_NEXT, $id + 1, false ); update_post_meta( $form_id, self::META_ID, $id ); $index = self::index(); $index[ $id ] = $form_id; update_option( self::OPTION_INDEX, $index, false ); return $id; } /** * The list of widget numbers. * * @return array */ private static function index() { $saved = get_option( self::OPTION_INDEX, array() ); return is_array( $saved ) ? $saved : array(); } /** * Which form stands behind a widget number. * * @param int $widget_id The widget number. * * @return int Zero when there is no such widget. */ public static function form_for( $widget_id ) { $index = self::index(); $widget_id = absint( $widget_id ); return isset( $index[ $widget_id ] ) ? absint( $index[ $widget_id ] ) : 0; } /** * Every form that has a widget chosen. * * @return array An array of arrays: `widgetId`, `formId`, `title`, `layout`. */ public static function all_widgets() { $out = array(); foreach ( self::index() as $widget_id => $form_id ) { $config = self::get( $form_id ); if ( 'none' === $config['layout'] || 'publish' !== get_post_status( $form_id ) ) { continue; } $out[] = array( 'widgetId' => absint( $widget_id ), 'formId' => absint( $form_id ), 'title' => get_the_title( $form_id ), 'layout' => $config['layout'], ); } return $out; } /** * Clears the widget number when a form is deleted, so the list does not grow * for no reason. * * @param int $post_id The id of the record being deleted. * * @return void */ public static function forget( $post_id ) { if ( Ranq_Forms_Post_Type::POST_TYPE !== get_post_type( $post_id ) ) { return; } $id = self::existing_id( $post_id ); if ( ! $id ) { return; } $index = self::index(); unset( $index[ $id ] ); update_option( self::OPTION_INDEX, $index, false ); } /** * The changes handed to the pattern. * * @param array $config The settings. * @param bool $slot Whether an empty slot goes in place of the form. * * @return array */ private static function pattern_args( $config, $slot ) { return array( 'phone' => '' !== $config['phone'] ? $config['phone'] : null, 'email' => '' !== $config['email'] ? $config['email'] : null, 'street' => '' !== $config['street'] ? $config['street'] : null, 'city' => '' !== $config['city'] ? $config['city'] : null, 'hours' => '' !== $config['hours'] ? $config['hours'] : null, 'heading' => '' !== $config['heading'] ? $config['heading'] : null, 'lead' => '' !== $config['lead'] ? $config['lead'] : null, 'image' => '' !== $config['image'] ? $config['image'] : null, 'bg' => '' !== $config['bg'] ? $config['bg'] : null, 'text' => '' !== $config['text'] ? $config['text'] : null, 'rows' => $config['rows'], 'values' => isset( $config['values'] ) ? $config['values'] : array(), 'slot' => (bool) $slot, ); } /** * Empty sections for the live preview in the builder. * * What comes back is a **really rendered section** with an empty slot where * the form goes, so the builder can drop the form a person is currently * assembling into that slot. That way the preview shows both the section and * the form, and no change needs a new request to the server. * * @param int $form_id The form id. * * @return array Layout key => HTML. */ public static function shells( $form_id ) { $config = self::get( $form_id ); $args = self::pattern_args( $config, true ); $out = array( 'none' => '
' ); foreach ( Ranq_Forms_Patterns::all( $args ) as $key => $pattern ) { $out[ $key ] = do_blocks( $pattern['content'] ); } return $out; } /** * One empty section, with settings that are not saved yet. * * It serves the live preview in the builder. The phone, the email and the * address sit inside links and under icons, so they cannot be rewritten in * the browser and the section is asked for again. That is the only part that * cannot refresh without the server. * * @param string $layout The layout key. * @param array $raw The raw settings. * * @return string */ public static function shell_for( $layout, $raw ) { $config = self::sanitize( $raw ); $args = self::pattern_args( $config, true ); if ( 'none' === $layout ) { return '
'; } $patterns = Ranq_Forms_Patterns::all( $args ); return isset( $patterns[ $layout ] ) ? do_blocks( $patterns[ $layout ]['content'] ) : ''; } /** * The whole section with the real form inside. * * @param int $form_id The form id. * * @return string */ public static function render_for_form( $form_id ) { $form_id = absint( $form_id ); $config = self::get( $form_id ); if ( 'none' === $config['layout'] ) { return Ranq_Forms_Shortcode::render( array( 'id' => $form_id ) ); } $patterns = Ranq_Forms_Patterns::all( self::pattern_args( $config, false ) ); if ( ! isset( $patterns[ $config['layout'] ] ) ) { return Ranq_Forms_Shortcode::render( array( 'id' => $form_id ) ); } $content = str_replace( '{"formId":0}', '{"formId":' . $form_id . '}', $patterns[ $config['layout'] ]['content'] ); return '
' . do_blocks( $content ) . '
'; } /** * `[2ranq_widget id="1001"]` * * @param array $atts The attributes. * * @return string */ public static function shortcode( $atts ) { $atts = shortcode_atts( array( 'id' => 0 ), $atts, '2ranq_widget' ); $form_id = self::form_for( $atts['id'] ); if ( ! $form_id ) { return ''; } Ranq_Forms_Shortcode::enqueue_sections(); return self::render_for_form( $form_id ); } }