'', 'alert' => '', 'arrow' => '', 'chevron' => '', 'copy' => '', 'check' => '', ); /** The SVG markup the screen prints. */ const SVG = array( 'svg' => array( 'class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true, 'focusable' => true, ), 'path' => array( 'd' => true, 'fill' => true, ), 'rect' => array( 'width' => true, 'height' => true, 'x' => true, 'y' => true, 'rx' => true, ), ); /** * Add the admin hooks. * * @return void */ public static function boot() { add_action( 'admin_menu', array( __CLASS__, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); add_action( 'admin_notices', array( __CLASS__, 'connection_notice' ) ); add_filter( 'plugin_action_links_' . plugin_basename( LAUNCHLIST_FILE ), array( __CLASS__, 'action_links' ) ); add_action( 'admin_post_launchlist_connect', array( Connection::class, 'handle_connect' ) ); add_action( 'admin_post_launchlist_callback', array( Connection::class, 'handle_callback' ) ); add_action( 'admin_post_nopriv_launchlist_callback', array( Connection::class, 'handle_callback_signed_out' ) ); add_action( 'admin_post_launchlist_disconnect', array( Connection::class, 'handle_disconnect' ) ); add_action( 'admin_post_launchlist_check', array( Connection::class, 'handle_check' ) ); add_action( 'admin_post_launchlist_save', array( __CLASS__, 'handle_save' ) ); add_action( 'admin_post_launchlist_retry', array( __CLASS__, 'handle_retry' ) ); } /** * The top-level menu item, with the form plugins it works with: just above Appearance (60), after * the separator WordPress puts at 59. * * @return void */ public static function menu() { add_menu_page( __( 'LaunchList', 'launchlist' ), __( 'LaunchList', 'launchlist' ), 'manage_options', self::SLUG, array( __CLASS__, 'render' ), self::icon(), 58 ); } /** * The stylesheet and script, on this screen only. * * @param string $hook_suffix The screen. * @return void */ public static function assets( $hook_suffix ) { if ( self::SCREEN !== $hook_suffix ) { return; } wp_enqueue_style( 'launchlist-admin', LAUNCHLIST_URL . 'assets/admin.css', array(), LAUNCHLIST_VERSION ); wp_enqueue_script( 'launchlist-admin', LAUNCHLIST_URL . 'assets/admin.js', array(), LAUNCHLIST_VERSION, true ); } /** * A "Settings" link on the Plugins screen. * * @param array $links Links. * @return array */ public static function action_links( $links ) { $links = is_array( $links ) ? $links : array(); $links[] = '' . esc_html__( 'Settings', 'launchlist' ) . ''; return $links; } /** * The one notice shown outside this screen: the connection stopped working. A copy of a connected * site is told only on the LaunchList screen: a staging site that never connects would otherwise * show it on every admin page for good. Revoked stays, because the founder can end it either way * (connect again, or disconnect this site). * * @return void */ public static function connection_notice() { $status = Connection::status(); if ( ! current_user_can( 'manage_options' ) || 'revoked' !== $status ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( $screen && self::SCREEN === $screen->id ) { return; } $message = __( 'LaunchList no longer recognizes this site, so signups from your forms are not reaching it. Connect again, or disconnect this site to stop this notice.', 'launchlist' ); printf( '

%s %s

', esc_html( $message ), esc_url( admin_url( 'admin.php?page=' . self::SLUG ) ), esc_html__( 'Open the LaunchList screen', 'launchlist' ) ); } /** * The screen. * * @return void */ public static function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } // Read the forms first: a refused request can change the connection's state. $loaded = self::load_forms(); $list = $loaded['list']; $status = Connection::status(); $site = Connection::site(); $counts = Queue::counts(); $routes = Routes::all(); $state = 'connected' === $status && $loaded['down'] ? 'unreachable' : $status; $active = Connection::is_active(); echo '
'; self::render_head( $state, $site ); echo '

'; foreach ( Notices::take() as $notice ) { printf( '

%s

', esc_attr( $notice['type'] ), esc_html( $notice['message'] ) ); } if ( 'not_connected' === $state ) { self::render_setup(); echo '
'; return; } self::render_problem( $state, $site, $counts ); if ( 'copy' === $state ) { self::render_add_to_page( $list ); echo ''; return; } if ( $active ) { self::render_connection( $site, $counts ); } self::render_forms( $list, $routes, $active ); if ( self::sending( $routes ) || $counts['pending'] || $counts['failed'] || Activity::all() ) { self::render_deliveries( $counts, in_array( $state, array( 'connected', 'clock_skew' ), true ) ); } self::render_add_to_page( $list ); echo ''; } /** * The forms list for the screen, and whether LaunchList could not be reached for it. While the * connection works the list is asked for fresh, so a form just made in LaunchList shows up; when * that fails, or every form is on hold after an outage, the cached list stands in. * * @return array{list: array|null, down: bool} */ private static function load_forms() { if ( ! Connection::is_active() ) { return array( 'list' => Forms::cached(), 'down' => false, ); } $list = null === Relay::site_hold() ? Forms::get( true ) : null; return array( 'list' => null !== $list ? $list : Forms::cached(), 'down' => null === $list, ); } /** * The header: the official logo and one status chip. * * @param string $state The screen's state. * @param array $site Connection details. * @return void */ private static function render_head( $state, array $site ) { $chips = array( 'not_connected' => array( '', __( 'Not connected', 'launchlist' ) ), 'connected' => array( 'ok', __( 'Connected', 'launchlist' ) ), 'clock_skew' => array( 'warn', __( 'Server clock is off', 'launchlist' ) ), 'unreachable' => array( 'warn', __( 'Can\'t reach LaunchList', 'launchlist' ) ), 'revoked' => array( 'warn', __( 'Not recognized', 'launchlist' ) ), /* translators: %s: the address of the site this one is a copy of. */ 'copy' => array( 'warn', sprintf( __( 'Copy of %s', 'launchlist' ), self::host( isset( $site['home'] ) ? (string) $site['home'] : '' ) ) ), ); $chip = isset( $chips[ $state ] ) ? $chips[ $state ] : $chips['connected']; echo '

' . esc_html__( 'LaunchList', 'launchlist' ) . '

'; echo wp_kses( self::logo(), self::SVG ); echo '

' . esc_html( $chip[1] ) . '

'; echo '
'; } /** * Not connected: one card that connects, and how to add a form without connecting. * * @return void */ private static function render_setup() { echo '
'; echo '
' . wp_kses( self::mark(), self::SVG ) . '
'; echo '

' . esc_html__( 'Connect this site to LaunchList', 'launchlist' ) . '

'; echo '

' . esc_html__( 'Send signups from your WordPress forms to LaunchList, with referral credit across pages.', 'launchlist' ) . '

'; self::action_button( 'launchlist_connect', __( 'Connect to LaunchList', 'launchlist' ), 'll-btn ll-btn--primary ll-btn--lg' ); echo '
'; echo '
' . esc_html__( 'Adding a LaunchList form to a page works without connecting.', 'launchlist' ) . ' ' . esc_html__( 'Show how', 'launchlist' ) . ''; echo '
'; self::render_howto(); echo '
'; } /** * How to add a form with a placeholder for its ID, for when the founder's forms are not known. * There is nothing to copy: a made-up ID would look real. * * @return void */ private static function render_howto() { echo '

' . esc_html__( 'In the block editor, add the LaunchList block and paste your form ID. Anywhere else, use this shortcode:', 'launchlist' ) . '

'; echo '
[launchlist form="' . esc_html__( 'your form ID', 'launchlist' ) . '"]
'; echo '

' . wp_kses( sprintf( /* translators: %s: the form ID prefix. */ esc_html__( 'Your form ID is in LaunchList under Embed & API. It starts with %s.', 'launchlist' ), 'frm_' ), array( 'code' => array() ) ) . '

'; } /** * A problem: one WordPress notice with one action. Nothing for a working connection. * * On a copy the only action connects the copy as a site of its own. Nothing here removes a * connection from a copy: its credentials are the live site's, and LaunchList would disconnect * the live site. * * @param string $state The screen's state. * @param array $site Connection details. * @param array $counts Queue counts. * @return void */ private static function render_problem( $state, array $site, array $counts ) { switch ( $state ) { case 'copy': /* translators: %s: the address of the site this one is a copy of. */ $message = sprintf( __( 'This looks like a copy of %s, so it sends nothing to LaunchList.', 'launchlist' ), self::host( isset( $site['home'] ) ? (string) $site['home'] : '' ) ); $actions = array( array( 'launchlist_connect', __( 'Connect this copy as its own site', 'launchlist' ), 'll-btn ll-btn--primary' ) ); break; case 'revoked': $message = __( 'LaunchList no longer recognizes this site. Signups from your forms are kept here for 24 hours.', 'launchlist' ); $actions = array( array( 'launchlist_connect', __( 'Connect again', 'launchlist' ), 'll-btn ll-btn--primary' ) ); break; case 'unreachable': $message = __( 'LaunchList can\'t be reached right now. Signups are kept here and sent when it\'s back.', 'launchlist' ); $actions = array( array( 'launchlist_retry', __( 'Retry now', 'launchlist' ), 'll-btn' ) ); break; case 'clock_skew': $skew = Connection::clock_skew(); $minutes = is_array( $skew ) && null !== $skew['offset'] ? (int) round( abs( $skew['offset'] ) / 60 ) : 0; $message = $minutes > 0 /* translators: %s: number of minutes. */ ? sprintf( _n( 'This server\'s clock is off by about %s minute, so LaunchList refuses its requests. Ask your host to sync the server clock, then check the connection.', 'This server\'s clock is off by about %s minutes, so LaunchList refuses its requests. Ask your host to sync the server clock, then check the connection.', $minutes, 'launchlist' ), number_format_i18n( $minutes ) ) : __( 'This server\'s clock is off, so LaunchList refuses its requests. Ask your host to sync the server clock, then check the connection.', 'launchlist' ); $actions = array( array( 'launchlist_check', __( 'Check connection', 'launchlist' ), 'll-btn' ) ); break; default: return; } echo '

' . esc_html( $message ) . '

'; foreach ( $actions as $action ) { self::action_button( $action[0], $action[1], $action[2] ); } // LaunchList let go of this site: removing it here is how the founder stops the notice. if ( 'revoked' === $state ) { self::render_disconnect( $counts ); } echo '
'; } /** * The connection row. * * @param array $site Connection details. * @param array $counts Queue counts. * @return void */ private static function render_connection( array $site, array $counts ) { $workspace = isset( $site['workspace'] ) && '' !== $site['workspace'] ? $site['workspace'] : __( 'your LaunchList workspace', 'launchlist' ); echo '
'; echo '
' . wp_kses( self::mark(), self::SVG ) . '
'; /* translators: %s: LaunchList workspace name. */ echo '

' . esc_html( sprintf( __( 'Connected to %s', 'launchlist' ), $workspace ) ) . ' ' . esc_html( self::host( home_url( '/' ) ) ) . '

'; echo '
'; if ( ! empty( $site['dashboard_url'] ) ) { printf( '%s%s %s', esc_url( $site['dashboard_url'] ), esc_html__( 'Open LaunchList', 'launchlist' ), wp_kses( self::icon_svg( 'external' ), self::SVG ), /* translators: Accessibility text. */ esc_html__( '(opens in a new tab)', 'launchlist' ) ); } self::render_disconnect( $counts ); echo '
'; } /** * Disconnect, confirmed in a dialog first (or the browser's confirm where a dialog cannot open). * * @param array $counts Queue counts. * @return void */ private static function render_disconnect( array $counts ) { $message = __( 'Your forms stop sending signups to LaunchList. Blocks and shortcodes keep working, and signups LaunchList already has stay there.', 'launchlist' ); $waiting = (int) $counts['pending'] + (int) $counts['failed']; if ( $waiting > 0 ) { $message .= ' ' . sprintf( /* translators: %s: number of signups. */ _n( '%s signup waiting to send is deleted.', '%s signups waiting to send are deleted.', $waiting, 'launchlist' ), number_format_i18n( $waiting ) ); } echo '
'; echo ''; wp_nonce_field( 'launchlist_disconnect' ); echo ''; echo ''; echo '

' . esc_html__( 'Disconnect this site?', 'launchlist' ) . '

'; echo '

' . esc_html( $message ) . '

'; echo '
'; echo '
'; echo '
'; } /** * Your forms: one row per form with one dropdown, and the one setting, saved together. * * @param array|null $list The forms list. * @param array $routes Saved routes. * @param bool $active Whether the connection works, so the list can be changed. * @return void */ private static function render_forms( $list, array $routes, $active ) { $available = self::available_adapters(); echo '
'; echo '

' . esc_html__( 'Your forms', 'launchlist' ) . '

'; echo '

' . esc_html__( 'Choose which forms send their signups to LaunchList.', 'launchlist' ) . '

'; if ( $active && null === $list ) { echo '

' . esc_html__( 'Your LaunchList forms couldn\'t be loaded, so this list can\'t be changed right now. Forms you already send keep sending.', 'launchlist' ) . '

'; return; } $rows = array(); foreach ( $available as $adapter ) { foreach ( self::adapter_forms( $adapter ) as $source_form ) { $rows[] = array( $adapter, $source_form ); } } if ( $active ) { echo '
'; echo ''; wp_nonce_field( 'launchlist_save' ); } if ( empty( $available ) ) { echo '

' . esc_html( self::supported_sentence( true ) ) . '

'; } elseif ( empty( $rows ) ) { $names = array_map( static function ( Integration $adapter ) { return $adapter->name(); }, $available ); /* translators: %s: comma-separated list of form plugin names. */ echo '

' . esc_html( sprintf( __( 'No forms in %s yet.', 'launchlist' ), implode( ', ', $names ) ) ) . '

'; } elseif ( is_array( $list ) && empty( $list['forms'] ) ) { echo '

' . esc_html__( 'Your LaunchList workspace has no active forms yet. Create one in LaunchList, then reload this page.', 'launchlist' ) . '

'; } else { self::render_rows( $rows, $list, $routes, $active ); } echo '
'; if ( $active ) { echo ''; } echo '
'; if ( $active ) { echo '
'; } echo ''; } /** * The rows of Your forms, and the data the script needs to redraw a field map. * * @param array $rows `[adapter, source form]` pairs. * @param array|null $list The forms list. * @param array $routes Saved routes. * @param bool $active Whether the list can be changed. * @return void */ private static function render_rows( array $rows, $list, array $routes, $active ) { $options = array(); $script = array( 'forms' => array(), 'sources' => array(), ); $forms = is_array( $list ) ? $list['forms'] : array(); foreach ( $forms as $form ) { $script['forms'][ $form['id'] ] = $form['fields']; $options[ $form['id'] ] = true; } echo '
    '; foreach ( $rows as $index => $row ) { $adapter = $row[0]; $source_form = $row[1]; $key = Routes::key( $adapter->slug(), $source_form['id'] ); $route = isset( $routes[ $key ] ) ? $routes[ $key ] : null; $current = null !== $route && $route['enabled'] ? $route['form'] : ''; $ll_form = Forms::find( $list, $current ); $fields = $active ? self::adapter_fields( $adapter, $source_form['id'] ) : array(); $name = 'routes[' . (int) $index . ']'; $map_id = 'launchlist-map-' . (int) $index; $sending = $active && null !== $ll_form; $missing = $sending && ! self::fills_identity( $route['map'], Forms::identity( $ll_form ), $fields ); $script['sources'][ $index ] = $fields; echo '
  • '; echo '
    '; echo '
    ' . esc_html( $source_form['title'] ) . '
    '; echo '
    ' . esc_html( $adapter->name() ) . ''; if ( $active ) { echo ''; } echo '
    '; if ( $active ) { echo '

    ' . wp_kses( self::icon_svg( 'alert' ), self::SVG ) . '' . esc_html( $missing ? self::missing_identity_text( $ll_form ) : '' ) . '

    '; } echo '
    '; if ( $active ) { echo ''; } /* translators: %s: form title. */ echo '
    '; if ( $active ) { echo ''; } echo '
  • '; } echo '
'; if ( ! $active ) { return; } $script['text'] = array( 'none' => __( 'Don\'t send', 'launchlist' ), 'noFields' => __( 'This form has no fields LaunchList can read.', 'launchlist' ), /* translators: %s: the name of the LaunchList field that identifies a signup, such as Email. */ 'missing' => __( 'No field fills %s, so LaunchList refuses these signups.', 'launchlist' ), /* translators: 1: field label, 2: field type such as email or text. */ 'fieldOption' => __( '%1$s (%2$s)', 'launchlist' ), ); wp_add_inline_script( 'launchlist-admin', 'window.launchlistAdmin = ' . wp_json_encode( $script ) . ';', 'before' ); } /** * The field map of one row: each LaunchList field, and which of the form's fields fills it. * Fields left unmatched still go with each signup as input data (PROTOCOL.md). * * @param int $index Row index. * @param array $ll_form The LaunchList form. * @param array $fields The source form's fields. * @param array $map LaunchList key => source field name. * @return void */ private static function render_map( $index, array $ll_form, array $fields, array $map ) { if ( empty( $fields ) ) { echo '

' . esc_html__( 'This form has no fields LaunchList can read.', 'launchlist' ) . '

'; return; } foreach ( $ll_form['fields'] as $position => $field ) { $prefix = 'routes[' . (int) $index . '][map][' . (int) $position . ']'; $id = 'launchlist-map-' . (int) $index . '-' . (int) $position; $chosen = isset( $map[ $field['key'] ] ) ? $map[ $field['key'] ] : ''; echo '
'; echo '' . wp_kses( self::icon_svg( 'arrow' ), self::SVG ) . ''; echo ''; echo '
'; } } /** * The warning on a row whose identity field nothing fills. * * @param array $ll_form The LaunchList form. * @return string */ private static function missing_identity_text( array $ll_form ) { $identity = Forms::identity( $ll_form ); $label = $identity['key']; foreach ( $ll_form['fields'] as $field ) { if ( $field['key'] === $identity['key'] ) { $label = $field['label']; break; } } /* translators: %s: the name of the LaunchList field that identifies a signup, such as Email. */ return sprintf( __( 'No field fills %s, so LaunchList refuses these signups.', 'launchlist' ), $label ); } /** * Recent deliveries: the last few outcomes, and what is waiting. * * @param array $counts Queue counts. * @param bool $can_retry Whether Retry now is offered here (not when the notice offers it). * @return void */ private static function render_deliveries( array $counts, $can_retry ) { $entries = array_slice( Activity::all(), 0, self::DELIVERIES ); echo '
'; echo '

' . esc_html__( 'Recent deliveries', 'launchlist' ) . '

'; if ( $counts['pending'] || $counts['failed'] ) { $parts = array(); if ( $counts['pending'] ) { /* translators: %s: number of signups. */ $parts[] = sprintf( _n( '%s signup is waiting to send.', '%s signups are waiting to send.', $counts['pending'], 'launchlist' ), number_format_i18n( $counts['pending'] ) ); } if ( $counts['failed'] ) { /* translators: %s: number of signups. */ $parts[] = sprintf( _n( '%s signup could not be sent.', '%s signups could not be sent.', $counts['failed'], 'launchlist' ), number_format_i18n( $counts['failed'] ) ); } echo '
' . esc_html( implode( ' ', $parts ) ) . ''; if ( $can_retry ) { self::action_button( 'launchlist_retry', __( 'Retry now', 'launchlist' ), 'll-link' ); } echo '
'; } if ( empty( $entries ) ) { echo '

' . esc_html__( 'Nothing sent yet. Submit one of the forms you send to LaunchList and the result shows up here.', 'launchlist' ) . '

'; return; } echo '
    '; foreach ( $entries as $entry ) { $status = Activity::status( $entry ); $reason = Activity::reason( $entry ); $title = '' !== $entry['source_form'] ? $entry['source_form'] : __( 'Untitled form', 'launchlist' ); $what = '' . esc_html( $title ) . ''; if ( '' !== $reason ) { $what = sprintf( /* translators: 1: form title, 2: why it was not sent or added. */ esc_html__( '%1$s: %2$s', 'launchlist' ), $what, '' . esc_html( $reason ) . '' ); } echo '
  • ' . esc_html( $status['label'] ) . ''; echo '' . wp_kses( $what, array( 'span' => array( 'class' => true ) ) ) . ''; echo '
  • '; } echo '
'; } /** * When a delivery happened: the time today, "Yesterday, 4:12 pm" the day before, else the date. * * @param int $time Unix time. * @return string */ public static function when( $time ) { $clock = get_option( 'time_format' ); $day = wp_date( 'Y-m-d', $time ); if ( wp_date( 'Y-m-d' ) === $day ) { /* translators: %s: the time of day. */ return sprintf( __( 'Today, %s', 'launchlist' ), wp_date( $clock, $time ) ); } if ( wp_date( 'Y-m-d', time() - DAY_IN_SECONDS ) === $day ) { /* translators: %s: the time of day. */ return sprintf( __( 'Yesterday, %s', 'launchlist' ), wp_date( $clock, $time ) ); } return wp_date( get_option( 'date_format' ) . ' ' . $clock, $time ); } /** * Add LaunchList to a page, folded away: the founder's own form IDs with copy buttons, or a * placeholder when their forms are not known. * * @param array|null $list The forms list. * @return void */ private static function render_add_to_page( $list ) { echo '
' . esc_html__( 'Add LaunchList to a page', 'launchlist' ) . '' . wp_kses( self::icon_svg( 'chevron' ), self::SVG ) . ''; echo '
'; if ( ! is_array( $list ) || empty( $list['forms'] ) ) { self::render_howto(); echo '
'; return; } $first = $list['forms'][0]; echo '

' . esc_html__( 'In the block editor, add the LaunchList block and pick your form. Anywhere else, use a shortcode:', 'launchlist' ) . '

'; if ( count( $list['forms'] ) > 1 ) { echo ''; } $codes = array( 'form' => array( __( 'Form', 'launchlist' ), __( 'Copy the form shortcode', 'launchlist' ), '[launchlist form="{form}"]' ), 'popup' => array( __( 'Popup', 'launchlist' ), __( 'Copy the popup shortcode', 'launchlist' ), '[launchlist popup="{form}" label="Join the waitlist"]' ), 'widget' => array( __( 'Widget', 'launchlist' ), __( 'Copy the widget shortcode', 'launchlist' ), '[launchlist widget="counter" project="{project}"]' ), 'updates' => array( __( 'Updates', 'launchlist' ), __( 'Copy the updates shortcode', 'launchlist' ), '[launchlist updates="{project}"]' ), ); echo '
    '; foreach ( $codes as $kind => $code ) { $id = 'launchlist-code-' . $kind; $text = str_replace( array( '{form}', '{project}' ), array( $first['id'], $first['project']['slug'] ), $code[2] ); echo '
  • ' . esc_html( $code[0] ) . ''; echo '' . esc_html( $text ) . ''; echo '
  • '; } echo '
'; echo '

' . esc_html__( 'A widget can also be a leaderboard, milestone or countdown: put that word in place of counter.', 'launchlist' ) . '

'; echo ''; } /** * `admin-post.php?action=launchlist_save`: Your forms and the setting, in one save. * * @return void */ public static function handle_save() { self::guard(); check_admin_referer( 'launchlist_save' ); $posted = isset( $_POST['routes'] ) && is_array( $_POST['routes'] ) ? map_deep( wp_unslash( $_POST['routes'] ), 'sanitize_text_field' ) : array(); $load = isset( $_POST['load_everywhere'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['load_everywhere'] ) ); $notice = self::save( $posted, $load ); Notices::add( $notice['type'], $notice['message'] ); Connection::back(); } /** * Save the routes a posted form asks for and the "load on every page" setting together. Nothing * is saved when the site is not connected, or when there are routes to check and the forms list * can't be loaded to check them against. * * @param array $posted `routes` from the request, sanitized. * @param bool $load Whether LaunchList loads on every page. * @return array{type: string, message: string} */ public static function save( array $posted, $load ) { if ( ! Connection::is_active() ) { return array( 'type' => 'error', 'message' => __( 'This site is not connected to LaunchList, so nothing was saved.', 'launchlist' ), ); } $result = array( 'routes' => null, 'missing_identity' => array(), ); if ( ! empty( $posted ) ) { $list = Forms::get(); if ( null === $list ) { return array( 'type' => 'error', 'message' => __( 'We couldn\'t load your LaunchList forms, so nothing was saved. Try again in a moment.', 'launchlist' ), ); } $result = self::routes_from_post( $posted, Routes::all(), $list, self::known_sources() ); } if ( null !== $result['routes'] ) { Routes::save( $result['routes'] ); } update_option( Embed::SETTINGS, array( 'load_everywhere' => (bool) $load ) ); if ( $result['missing_identity'] ) { return array( 'type' => 'warning', 'message' => sprintf( /* translators: %s: comma-separated form titles. */ __( 'Saved, but LaunchList will refuse signups from these forms until a field is matched to the field that identifies a signup: %s. Open Match fields to choose it.', 'launchlist' ), implode( ', ', $result['missing_identity'] ) ), ); } return array( 'type' => 'success', 'message' => __( 'Saved', 'launchlist' ), ); } /** * The routes a posted form asks for, each value checked against what the screen offered: the * route key against the active adapters' forms, the LaunchList form against the forms list, each * field key against that form's fields, each source field against that form's fields. Posted * values arrive through `sanitize_text_field()`, so each list is compared in that form too, and * the original value is what gets stored. Choosing a LaunchList form sends the form; "Don't send" * removes its route. A row whose LaunchList form is not in the list keeps its saved route. * * @param array $posted `routes` from the request, sanitized. * @param array $routes The saved routes. * @param array $list The forms list. * @param array $sources Route key => `{title, fields}` of every source form on screen. * @return array{routes: array, missing_identity: array} */ public static function routes_from_post( array $posted, array $routes, array $list, array $sources ) { $missing = array(); $keys = self::allowlist( array_keys( $sources ) ); foreach ( $posted as $row ) { if ( ! is_array( $row ) || ! isset( $row['key'] ) || ! is_string( $row['key'] ) || ! isset( $keys[ $row['key'] ] ) ) { continue; } $key = $keys[ $row['key'] ]; $form = isset( $row['form'] ) && is_string( $row['form'] ) ? trim( $row['form'] ) : ''; if ( '' === $form ) { unset( $routes[ $key ] ); continue; } $ll_form = Forms::find( $list, $form ); if ( null === $ll_form ) { continue; } $source_fields = $sources[ $key ]['fields']; $ll_keys = self::allowlist( array_column( $ll_form['fields'], 'key' ) ); $source_names = self::allowlist( array_column( $source_fields, 'name' ) ); $map = array(); if ( isset( $row['map'] ) && is_array( $row['map'] ) ) { foreach ( $row['map'] as $pair ) { if ( ! is_array( $pair ) || ! isset( $pair['ll'], $pair['source'] ) || ! is_string( $pair['ll'] ) || ! is_string( $pair['source'] ) || ! isset( $ll_keys[ $pair['ll'] ] ) ) { continue; } if ( '' === $pair['source'] ) { $map[ $ll_keys[ $pair['ll'] ] ] = ''; } elseif ( isset( $source_names[ $pair['source'] ] ) ) { $map[ $ll_keys[ $pair['ll'] ] ] = $source_names[ $pair['source'] ]; } } } // No map for this form posted (it was just chosen, without JavaScript): use the suggestion. if ( empty( $map ) ) { $map = self::suggest( $ll_form['fields'], $source_fields ); } $identity = Forms::identity( $ll_form ); $routes[ $key ] = array( 'form' => $ll_form['id'], 'enabled' => true, 'map' => $map, 'identity' => $identity['key'], 'identity_type' => $identity['type'], ); if ( ! self::fills_identity( $map, $identity, $source_fields ) ) { $missing[] = $sources[ $key ]['title']; } } return array( 'routes' => $routes, 'missing_identity' => $missing, ); } /** * Suggested matches: the field LaunchList identifies a signup by (and any other email field) * from the first field of its type, then the rest by key or label, ignoring case, spaces and * punctuation. * * @param array $ll_fields The LaunchList form's fields. * @param array $source_fields The source form's fields. * @return array LaunchList key => source field name. */ public static function suggest( array $ll_fields, array $source_fields ) { $map = array(); $used = array(); $by_type = static function ( $field ) use ( $source_fields, &$used ) { $type = strtolower( (string) $field['type'] ); $types = 'phone' === $type || 'tel' === $type ? array( 'tel' ) : ( 'email' === $type || ( '' === $type && ! empty( $field['identity'] ) ) ? array( 'email' ) : array() ); foreach ( $source_fields as $source ) { if ( ! isset( $used[ $source['name'] ] ) && in_array( $source['type'], $types, true ) ) { return $source['name']; } } return null; }; foreach ( $ll_fields as $field ) { if ( empty( $field['identity'] ) ) { continue; } $name = $by_type( $field ); if ( null !== $name ) { $map[ $field['key'] ] = $name; $used[ $name ] = true; } } foreach ( $ll_fields as $field ) { if ( isset( $map[ $field['key'] ] ) ) { continue; } $wanted = array_filter( array( self::normalize( $field['key'] ), self::normalize( $field['label'] ) ) ); foreach ( $source_fields as $source ) { if ( isset( $used[ $source['name'] ] ) ) { continue; } if ( in_array( self::normalize( $source['name'] ), $wanted, true ) || in_array( self::normalize( $source['label'] ), $wanted, true ) ) { $map[ $field['key'] ] = $source['name']; $used[ $source['name'] ] = true; break; } } } foreach ( $ll_fields as $field ) { if ( isset( $map[ $field['key'] ] ) || 'email' !== strtolower( (string) $field['type'] ) ) { continue; } $name = $by_type( $field ); if ( null !== $name ) { $map[ $field['key'] ] = $name; $used[ $name ] = true; } } return $map; } /** * `admin-post.php?action=launchlist_retry`: send every waiting signup now, as far as one run * gets in its 20 seconds; the rest follow at once in the background. Also lifts the hold an * outage left, so the screen asks LaunchList again. * * @return void */ public static function handle_retry() { self::guard(); check_admin_referer( 'launchlist_retry' ); $counts = Queue::counts(); if ( 0 === $counts['pending'] + $counts['failed'] ) { Queue::retry_now(); Notices::add( 'info', __( 'Nothing is waiting to be sent.', 'launchlist' ) ); Connection::back(); } if ( ! Connection::is_active() ) { Notices::add( 'error', __( 'This site is not connected to LaunchList, so nothing could be sent. Connect again, then retry.', 'launchlist' ) ); Connection::back(); } Queue::retry_now(); $tally = Queue::process(); $parts = array(); if ( $tally['sent'] ) { /* translators: %s: number of signups. */ $parts[] = sprintf( _n( '%s signup sent.', '%s signups sent.', $tally['sent'], 'launchlist' ), number_format_i18n( $tally['sent'] ) ); } if ( $tally['refused'] ) { /* translators: %s: number of signups. */ $parts[] = sprintf( _n( '%s not added by LaunchList.', '%s not added by LaunchList.', $tally['refused'], 'launchlist' ), number_format_i18n( $tally['refused'] ) ); } if ( $tally['waiting'] ) { /* translators: %s: number of signups. */ $parts[] = sprintf( _n( '%s still waiting: LaunchList could not take it yet.', '%s still waiting: LaunchList could not take them yet.', $tally['waiting'], 'launchlist' ), number_format_i18n( $tally['waiting'] ) ); } if ( $tally['failed'] ) { /* translators: %s: number of signups. */ $parts[] = sprintf( _n( '%s could not be sent.', '%s could not be sent.', $tally['failed'], 'launchlist' ), number_format_i18n( $tally['failed'] ) ); } $remaining = Queue::counts(); if ( empty( $parts ) && null !== Relay::site_hold() ) { Notices::add( 'warning', __( 'LaunchList could not be reached a moment ago, so nothing was sent. Waiting signups are tried again in the background.', 'launchlist' ) ); } elseif ( empty( $parts ) ) { Notices::add( 'info', __( 'Another delivery run is in progress, so nothing was sent from here. Check again in a minute.', 'launchlist' ) ); } else { $more = $remaining['pending'] > $tally['waiting'] ? ' ' . __( 'The rest are being sent in the background.', 'launchlist' ) : ''; Notices::add( $tally['sent'] && ! $tally['failed'] && ! $tally['waiting'] ? 'success' : 'warning', implode( ' ', $parts ) . $more ); } Connection::back(); } /** * Whether any route is on. * * @param array $routes Routes. * @return bool */ private static function sending( array $routes ) { foreach ( $routes as $route ) { if ( ! empty( $route['enabled'] ) ) { return true; } } return false; } /** * The LaunchList forms as options, under a heading per project when the workspace has more than * one, so each form shows by its own name. * * @param array $forms Forms from the list. * @param string $current The chosen form ID, or "". * @param bool $with_project Whether each option carries its project's slug for the shortcodes. * @return void */ private static function render_form_options( array $forms, $current, $with_project = false ) { $groups = array(); foreach ( $forms as $form ) { // A prefix keeps a numeric project name a string key. $groups[ 'p' . $form['project']['name'] ][] = $form; } $headed = count( $groups ) > 1; foreach ( $groups as $project => $group ) { $label = substr( $project, 1 ); if ( $headed && '' !== $label ) { echo ''; } foreach ( $group as $form ) { $data = $with_project ? ' data-project="' . esc_attr( $form['project']['slug'] ) . '"' : ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $data is escaped above. } if ( $headed && '' !== $label ) { echo ''; } } } /** * The host of an address, with its path when it has one. * * @param string $url The address. * @return string */ private static function host( $url ) { $host = (string) wp_parse_url( $url, PHP_URL_HOST ); $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ); return '' === $host ? $url : $host . ( '' !== $path ? '/' . $path : '' ); } /** * A lookup from the sanitized form of each value to the value itself. * * @param array $values Values. * @return array */ private static function allowlist( array $values ) { $lookup = array(); foreach ( $values as $value ) { $lookup[ sanitize_text_field( (string) $value ) ] = (string) $value; } return $lookup; } /** * Every source form on screen: route key => `{title, fields}`. * * @return array */ private static function known_sources() { $sources = array(); foreach ( self::available_adapters() as $adapter ) { foreach ( self::adapter_forms( $adapter ) as $form ) { $sources[ Routes::key( $adapter->slug(), $form['id'] ) ] = array( 'title' => $form['title'], 'fields' => self::adapter_fields( $adapter, $form['id'] ), ); } } return $sources; } /** * Whether a map (or the relay's fallback) fills the identity field. * * @param array $map The map. * @param array $identity `{key, type}`. * @param array $source_fields The source form's fields. * @return bool */ private static function fills_identity( array $map, array $identity, array $source_fields ) { if ( isset( $map[ $identity['key'] ] ) && '' !== $map[ $identity['key'] ] ) { return true; } $type = strtolower( $identity['type'] ); $types = 'phone' === $type || 'tel' === $type ? array( 'tel' ) : ( '' === $type || 'email' === $type ? array( 'email' ) : array() ); foreach ( $source_fields as $field ) { if ( in_array( $field['type'], $types, true ) && ! in_array( $field['name'], $map, true ) ) { return true; } } return false; } /** * The active adapters, never letting one break the screen. * * @return array */ private static function available_adapters() { try { return Integrations::available(); } catch ( \Throwable $e ) { return array(); } } /** * One adapter's forms, cleaned. * * @param Integration $adapter The adapter. * @return array */ private static function adapter_forms( Integration $adapter ) { try { $forms = $adapter->forms(); } catch ( \Throwable $e ) { return array(); } $clean = array(); foreach ( is_array( $forms ) ? $forms : array() as $form ) { if ( ! is_array( $form ) || ! isset( $form['id'] ) || '' === (string) $form['id'] ) { continue; } $title = isset( $form['title'] ) && '' !== trim( (string) $form['title'] ) ? (string) $form['title'] : __( 'Untitled form', 'launchlist' ); $clean[] = array( 'id' => (string) $form['id'], 'title' => wp_strip_all_tags( $title ), ); } return $clean; } /** * One form's fields, cleaned. * * @param Integration $adapter The adapter. * @param string $form_id The form's ID. * @return array */ private static function adapter_fields( Integration $adapter, $form_id ) { try { $fields = $adapter->fields( $form_id ); } catch ( \Throwable $e ) { return array(); } $clean = array(); $seen = array(); foreach ( is_array( $fields ) ? $fields : array() as $field ) { if ( ! is_array( $field ) || ! isset( $field['name'] ) || '' === (string) $field['name'] || isset( $seen[ (string) $field['name'] ] ) ) { continue; } $name = (string) $field['name']; $seen[ $name ] = true; $type = isset( $field['type'] ) ? strtolower( (string) $field['type'] ) : 'other'; $clean[] = array( 'name' => $name, 'label' => isset( $field['label'] ) ? wp_strip_all_tags( (string) $field['label'] ) : '', 'type' => in_array( $type, Integration::TYPES, true ) ? $type : 'other', ); } return $clean; } /** * A source field as an option label. * * @param array $field `{name, label, type}`. * @return string */ private static function field_label( array $field ) { $label = '' !== trim( $field['label'] ) ? $field['label'] : $field['name']; /* translators: 1: field label, 2: field type such as email or text. */ return sprintf( __( '%1$s (%2$s)', 'launchlist' ), $label, $field['type'] ); } /** * Lowercase letters and digits only, for matching names to labels. * * @param string $value The value. * @return string */ private static function normalize( $value ) { $value = function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $value, 'UTF-8' ) : strtolower( (string) $value ); return (string) preg_replace( '/[^\p{L}\p{N}]+/u', '', $value ); } /** * Which form plugins LaunchList works with, as a sentence. * * @param bool $none_active Whether none of them is active here. * @return string */ public static function supported_sentence( $none_active ) { $list = __( 'Contact Form 7, WPForms, Gravity Forms, Elementor Pro forms, Fluent Forms, Ninja Forms and Formidable Forms', 'launchlist' ); if ( $none_active ) { /* translators: %s: list of form plugin names. */ return sprintf( __( 'None of the form plugins LaunchList works with is active on this site. LaunchList works with %s.', 'launchlist' ), $list ); } /* translators: %s: list of form plugin names. */ return sprintf( __( 'LaunchList works with %s.', 'launchlist' ), $list ); } /** * A one-button form posting to `admin-post.php`. * * @param string $action The admin-post action, also the nonce action. * @param string $label Button text. * @param string $classes Button classes. * @return void */ private static function action_button( $action, $label, $classes ) { echo '
'; echo ''; wp_nonce_field( $action ); echo ''; echo '
'; } /** * Refuse anyone who may not manage the site. Each handler then checks its own nonce. * * @return void */ private static function guard() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to change LaunchList settings.', 'launchlist' ), '', array( 'response' => 403 ) ); } } /** * The official logo, for the header. The screen's heading names it, so it is not announced. * * @return string */ private static function logo() { return ''; } /** * The official mark, in its two colors, for light surfaces. * * @return string */ private static function mark() { return ''; } /** * A stroked icon. * * @param string $name One of {@see self::ICONS}. * @param string $classes Extra classes. * @return string */ private static function icon_svg( $name, $classes = '' ) { return ''; } /** * The menu icon: the official mark's two chevrons in one color, which WordPress repaints for * each admin color scheme. * * @return string */ private static function icon() { $svg = ''; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- A data URI, as add_menu_page() expects. return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } }