$type ) { add_filter( 'pre_option_' . $name, array( __CLASS__, 'pre_option' ), 10, 2 ); if ( in_array( $type, array( 'text', 'number' ), true ) ) { add_filter( 'option_' . $name, array( __CLASS__, 'expand_option' ), 10, 2 ); } } } /** Wire hooks. */ public static function register() { add_action( 'admin_menu', array( __CLASS__, 'menu' ), 20 ); add_action( 'network_admin_menu', array( __CLASS__, 'network_menu' ), 20 ); add_action( 'network_admin_edit_mapp_save_network_settings', array( __CLASS__, 'save_network' ) ); add_action( 'admin_post_mapp_save_settings', array( __CLASS__, 'save' ) ); add_action( 'admin_notices', array( __CLASS__, 'key_notice' ) ); } /** Submenu under Applications. */ public static function menu() { add_submenu_page( 'edit.php?post_type=mortgage_application', __( 'Form & CRM', '1003-mortgage-application' ), __( 'Form & CRM', '1003-mortgage-application' ), 'manage_options', self::SLUG, array( __CLASS__, 'render' ) ); } /** Under the 2.6 network menu (MTG Application). */ public static function network_menu() { add_submenu_page( 'ma_setting', __( 'Form & CRM', '1003-mortgage-application' ), __( 'Form & CRM', '1003-mortgage-application' ), 'manage_network_options', self::SLUG, array( __CLASS__, 'render_network' ) ); } /** @return bool Whether this site reads the network's values. */ public static function uses_network() { return self::network_saved() && ! is_network_admin() && 'no' !== get_option( self::USE_NETWORK, 'yes' ); } /** @return bool Whether the network page has been saved (until then every site uses its own values). */ public static function network_saved() { return is_multisite() && (bool) get_site_option( 'mapp_network_settings_saved', false ); } /** * pre_option_*: a site on network settings gets the network value. * * @param mixed $pre Short-circuit value. * @param string $option Option name. * @return mixed */ public static function pre_option( $pre, $option ) { if ( false !== $pre || self::$raw || ! self::uses_network() ) { return $pre; } $value = get_site_option( $option, null ); if ( null === $value || false === $value ) { return $pre; } return self::expand( $option, $value ); } /** * option_*: run shortcodes in a site's own value. * * @param mixed $value Value. * @param string $option Option name. * @return mixed */ public static function expand_option( $value, $option ) { return self::$raw ? $value : self::expand( $option, $value ); } /** * Shortcodes in text and number values run on the site reading them, so * one network value such as [site_nmls] can differ per site. * * @param string $option Option name. * @param mixed $value Value. * @return mixed */ public static function expand( $option, $value ) { $types = self::types(); if ( ! is_string( $value ) || false === strpos( $value, '[' ) || ! isset( $types[ $option ] ) || ! in_array( $types[ $option ], array( 'text', 'number' ), true ) ) { return $value; } // A shortcode this site does not have shows blank, not as raw [tag] text. $out = trim( preg_replace( '/\[\/?[a-z0-9_\-]+(?:\s[^\]]*)?\]/i', '', wp_strip_all_tags( do_shortcode( $value ) ) ) ); return 'number' === $types[ $option ] ? ( is_numeric( $out ) ? $out : '' ) : $out; } /** * @param string $value Value as typed. * @return string[] Shortcode names in it that this site does not register. */ public static function unknown_shortcodes( $value ) { preg_match_all( '/\[([a-z0-9_\-]+)(?:\s[^\]]*)?\]/i', (string) $value, $m ); return array_values( array_unique( array_filter( $m[1], function ( $tag ) { return ! shortcode_exists( $tag ); } ) ) ); } /** @return array option => type, for every value the network can set. */ public static function types() { static $out = null; if ( null === $out ) { $out = array(); foreach ( self::fields() as $fields ) { foreach ( $fields as $name => $spec ) { $out[ $name ] = $spec[1]; } } // List values the Pro module stores (webhook events). $out = (array) apply_filters( 'mapp_form_crm_types', $out ); } return $out; } /** * A posted value, cleaned for its type. Text and number fields keep * shortcodes as typed. * * @param array $spec Field spec. * @param mixed $raw Posted value. * @return string */ private static function clean( $spec, $raw ) { $raw = is_string( $raw ) ? trim( $raw ) : ''; switch ( $spec[1] ) { case 'yesno': return 'yes' === $raw ? 'yes' : 'no'; case 'number': if ( false !== strpos( $raw, '[' ) ) { return sanitize_text_field( $raw ); } return '' === $raw ? '' : (string) (float) $raw; case 'select': return array_key_exists( $raw, $spec[3] ) ? $raw : $spec[2]; default: return sanitize_text_field( $raw ); } } /** * Text, checkbox and select fields: option => [ label, type, default, choices|help ]. * * @return array */ public static function fields() { // The Pro module adds the GHL and Webhooks sections (before Security). return (array) apply_filters( 'mapp_form_crm_fields', array( __( 'Company', '1003-mortgage-application' ) => array( 'mapp_company_name' => array( __( 'Company name (shown in consent and footer)', '1003-mortgage-application' ), 'text', '', __( 'Blank: the site name, so on a network each site shows its own.', '1003-mortgage-application' ) ), 'mapp_company_nmls' => array( __( 'Company NMLS #', '1003-mortgage-application' ), 'text', '' ), 'mapp_company_phone' => array( __( 'Company phone', '1003-mortgage-application' ), 'text', '' ), ), __( 'Alerts and reminders', '1003-mortgage-application' ) => array( 'mapp_alert_partial' => array( __( 'Email the LO when contact details are saved (before submit)', '1003-mortgage-application' ), 'yesno', 'yes' ), 'mapp_notify_site_too' => array( __( 'Also copy the site recipients on every LO alert', '1003-mortgage-application' ), 'yesno', 'no' ), 'mapp_reminders' => array( __( 'Remind borrowers who did not finish', '1003-mortgage-application' ), 'yesno', 'yes', __( 'Short application: 24 and 72 hours after they save their contact details (text on the Settings page). Full 1003: 2 and 5 days after they last worked on it (built-in text). Each has a fresh link.', '1003-mortgage-application' ) ), 'mapp_borrower_numbers' => array( __( 'Email borrowers their numbers and a resume link after the contact step', '1003-mortgage-application' ), 'yesno', 'yes' ), ), __( 'Numbers screen', '1003-mortgage-application' ) => array( 'mapp_reveal_payment' => array( __( 'Show an estimated principal and interest payment', '1003-mortgage-application' ), 'yesno', 'no', __( 'A payment amount in an advertisement can trigger Regulation Z disclosures. Have compliance review before turning this on.', '1003-mortgage-application' ) ), 'mapp_reveal_rate' => array( __( 'Rate used for the estimate (%)', '1003-mortgage-application' ), 'number', '' ), 'mapp_reveal_cash_ltv' => array( __( 'Maximum loan-to-value for cash-out estimates (%)', '1003-mortgage-application' ), 'number', '80' ), ), __( 'Security', '1003-mortgage-application' ) => array( 'mapp_resume_days' => array( __( 'Resume links stay valid for (days)', '1003-mortgage-application' ), 'number', '30' ), // Same option as the 2.x "Delete File After" setting, so saved values carry over. 'mortgage_ma_submissions_deleted_file' => array( __( 'Delete uploaded documents after (days)', '1003-mortgage-application' ), 'number', '', __( 'Documents borrowers upload are deleted this many days after upload, and each deletion is logged. Blank or 0 keeps them.', '1003-mortgage-application' ) ), 'mapp_turnstile_site_key' => array( __( 'Cloudflare Turnstile site key (optional)', '1003-mortgage-application' ), 'text', '' ), 'mapp_turnstile_secret' => array( __( 'Cloudflare Turnstile secret', '1003-mortgage-application' ), 'secret', '' ), ), ) ); } /** Page. */ public static function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } echo '
'; Ui::head( __( 'Form & CRM', '1003-mortgage-application' ), __( 'How the application behaves and where its data goes. Email templates, labels and colors are on the Settings page.', '1003-mortgage-application' ) ); Ui::message(); $up = Upgrade::state(); echo '
'; // The Pro module adds the GHL rows (connection, opportunities, custom fields, documents). do_action( 'mapp_form_crm_status' ); echo '
' . esc_html__( 'Encryption key', '1003-mortgage-application' ) . '
' . esc_html( Crypto::key_in_config() ? __( 'From MAPP_ENCRYPTION_KEY in wp-config.php.', '1003-mortgage-application' ) : __( 'Stored in the database. Add MAPP_ENCRYPTION_KEY to wp-config.php for stronger protection (read the readme before changing it on a live site).', '1003-mortgage-application' ) ) . '
'; echo '
' . esc_html__( 'Upgrade from 2.x', '1003-mortgage-application' ) . '
' . esc_html( Upgrade::done() ? sprintf( /* translators: 1: files, 2: SSNs */ __( 'Done. %1$d legacy uploads moved to private storage, %2$d SSNs re-encrypted.', '1003-mortgage-application' ), $up['imported'], $up['ssn_done'] ) : __( 'In progress (runs in small batches on admin page loads).', '1003-mortgage-application' ) ) . ( $up['errors'] ? ' ' . esc_html( end( $up['errors'] ) ) : '' ) . '
'; $receipt = \Mapp\Notifications\Mailer::receipt_on(); echo '
' . esc_html__( 'Borrower receipt', '1003-mortgage-application' ) . '
' . esc_html( $receipt ? __( 'Sent when the short app is submitted.', '1003-mortgage-application' ) : __( 'Off. Turn on "Client Email Send" on the Settings page to send borrowers their receipt.', '1003-mortgage-application' ) ) . '
'; echo '
'; echo '
'; wp_nonce_field( 'mapp_save_settings' ); echo ''; do_action( 'mapp_form_crm_connection' ); $network = self::uses_network(); if ( self::network_saved() ) { echo '

' . esc_html__( 'Network settings', '1003-mortgage-application' ) . '

'; } self::$raw = true; self::field_tables( function ( $name, $default ) use ( $network ) { return $network ? get_site_option( $name, $default ) : get_option( $name, $default ); }, $network, false ); self::$raw = false; submit_button( __( 'Save changes', '1003-mortgage-application' ) ); echo '
'; ob_start(); do_action( 'mapp_form_crm_tools' ); $tools = ob_get_clean(); if ( '' !== trim( $tools ) ) { echo '

' . esc_html__( 'Tools', '1003-mortgage-application' ) . '

' . $tools . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by the tools. } echo '
'; } /** * The Form & CRM field tables. * * @param callable $get fn( name, default ) → stored value as typed. * @param bool $disabled Read-only (the site is on network settings). * @param bool $network Rendering the network page. */ private static function field_tables( $get, $disabled, $network ) { $off = $disabled ? ' disabled' : ''; echo '

' . esc_html__( 'Text and number fields accept shortcodes. They run on the site that uses the value, so one network value such as [site_nmls] can show something different on each site.', '1003-mortgage-application' ) . '

'; foreach ( self::fields() as $section => $fields ) { echo '

' . esc_html( $section ) . '

'; foreach ( $fields as $name => $spec ) { $value = $get( $name, $spec[2] ); echo ''; } do_action( 'mapp_form_crm_section_end', $section, $get, $off, $network ); echo ''; } } /** * Write the posted Form & CRM values. * * @param callable $write fn( name, value ). * @param callable $read fn( name ) → stored value, for the payment check. * @return string Notice. */ private static function save_fields( $write, $read ) { // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce checked by the caller; clean() sanitizes. foreach ( self::fields() as $fields ) { foreach ( $fields as $name => $spec ) { $raw = isset( $_POST[ $name ] ) ? wp_unslash( $_POST[ $name ] ) : ''; if ( 'secret' === $spec[1] ) { if ( '' !== trim( (string) $raw ) ) { $write( $name, sanitize_text_field( $raw ) ); } continue; } $write( $name, self::clean( $spec, $raw ) ); } } // phpcs:enable do_action( 'mapp_form_crm_save_fields', $write ); // No rate, no estimate: the screen would show $0 or nothing. A // shortcode rate is checked as this site resolves it. $rate = (float) self::expand( 'mapp_reveal_rate', (string) $read( 'mapp_reveal_rate' ) ); if ( 'yes' === $read( 'mapp_reveal_payment' ) && $rate <= 0 ) { $write( 'mapp_reveal_payment', 'no' ); return __( 'Settings saved. The payment estimate stays off until you enter a rate.', '1003-mortgage-application' ); } return __( 'Settings saved.', '1003-mortgage-application' ); } /** Save (site). */ public static function save() { check_admin_referer( 'mapp_save_settings' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', '1003-mortgage-application' ), 403 ); } self::$raw = true; $notice = __( 'Settings saved.', '1003-mortgage-application' ); $was = self::uses_network(); if ( self::network_saved() ) { update_option( self::USE_NETWORK, isset( $_POST[ self::USE_NETWORK ] ) ? 'yes' : 'no', false ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above. } $now = self::uses_network(); if ( $was && ! $now ) { // The fields were read-only, so none of this site's values were posted. $notice = __( 'This site now uses its own values. Review them below and save.', '1003-mortgage-application' ); } elseif ( ! $now ) { $notice = self::save_fields( function ( $name, $value ) { update_option( $name, $value, false ); }, function ( $name ) { return get_option( $name, '' ); } ); } do_action( 'mapp_form_crm_saved' ); self::$raw = false; \Mapp\Schema\Schema::flush(); self::back( $notice ); } /** Network page. */ public static function render_network() { if ( ! current_user_can( 'manage_network_options' ) ) { return; } echo '
'; Ui::head( __( 'Form & CRM (network)', '1003-mortgage-application' ), __( 'Every site uses these values unless it turns off "Use the network settings" on its own Form & CRM page. Connections and secrets stay each site\'s own.', '1003-mortgage-application' ) ); Ui::message(); echo '
'; wp_nonce_field( 'mapp_save_network_settings' ); self::$raw = true; self::field_tables( function ( $name, $default ) { return get_site_option( $name, $default ); }, false, true ); self::$raw = false; submit_button(); echo '
'; } /** Save (network). */ public static function save_network() { check_admin_referer( 'mapp_save_network_settings' ); if ( ! current_user_can( 'manage_network_options' ) ) { wp_die( esc_html__( 'Not allowed.', '1003-mortgage-application' ), 403 ); } self::$raw = true; $kept = self::network_saved() ? 0 : self::keep_site_values(); $notice = self::save_fields( function ( $name, $value ) { update_site_option( $name, $value ); }, function ( $name ) { return get_site_option( $name, '' ); } ); if ( $kept ) { /* translators: %d: number of sites */ $notice .= ' ' . sprintf( _n( '%d site already had its own Form & CRM values and keeps them; turn on "Use the network settings" on that site to switch.', '%d sites already had their own Form & CRM values and keep them; turn on "Use the network settings" on those sites to switch.', $kept, '1003-mortgage-application' ), $kept ); } update_site_option( 'mapp_network_settings_saved', time() ); self::$raw = false; wp_safe_redirect( add_query_arg( array( 'page' => self::SLUG, 'mapp_msg' => rawurlencode( $notice ) ), network_admin_url( 'admin.php' ) ) ); exit; } /** * First network save: a site that already saved its own values keeps * them (its switch goes off) instead of silently taking the network's. * * @return int Sites switched to their own values. */ public static function keep_site_values() { $kept = 0; $was_raw = self::$raw; self::$raw = true; // The site's own stored values, not the network's. foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) { switch_to_blog( $site_id ); if ( null === get_option( self::USE_NETWORK, null ) ) { foreach ( array_keys( self::types() ) as $name ) { $value = get_option( $name, null ); // The upgrade sets the webhook format on every site; only "form" (2.6 receivers) is the site's own. if ( null !== $value && ( 'mapp_webhook_format' !== $name || 'form' === $value ) ) { update_option( self::USE_NETWORK, 'no', false ); ++$kept; break; } } } restore_current_blog(); } self::$raw = $was_raw; return $kept; } /** * @param string $msg Message. */ public static function back( $msg ) { wp_safe_redirect( add_query_arg( array( 'page' => self::SLUG, 'post_type' => 'mortgage_application', 'mapp_msg' => rawurlencode( $msg ) ), admin_url( 'edit.php' ) ) ); exit; } /** Nudge to move the key into wp-config.php, on this plugin's screens only. */ public static function key_notice() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || false === strpos( (string) $screen->id, 'mortgage_application' ) || Crypto::key_in_config() || ! current_user_can( 'manage_options' ) ) { return; } if ( get_user_meta( get_current_user_id(), 'mapp_key_notice_seen', true ) ) { return; } update_user_meta( get_current_user_id(), 'mapp_key_notice_seen', 1 ); echo '

' . esc_html__( '1003 Mortgage Application: SSNs and documents are encrypted with a key stored in the database. For stronger protection, define MAPP_ENCRYPTION_KEY in wp-config.php (instructions in the readme).', '1003-mortgage-application' ) . '

'; } }