' ',
'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(
'
',
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(
'
',
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 '';
}
/**
* 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 '';
}
/**
* 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 '
';
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 '';
}
/**
* 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 '' . esc_html__( 'Match fields', 'launchlist' ) . ' ';
}
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 '
';
echo '' . esc_html__( 'Don\'t send', 'launchlist' ) . ' ';
self::render_form_options( $forms, $current );
if ( '' !== $current && ! isset( $options[ $current ] ) ) {
/* translators: %s: LaunchList form ID. */
$label = is_array( $list ) ? sprintf( __( '%s (not in this workspace)', 'launchlist' ), $current ) : $current;
echo '' . esc_html( $label ) . ' ';
}
echo ' ';
if ( $active ) {
echo '';
if ( $sending ) {
// Saved choices win; a field never saved shows the suggestion.
self::render_map( $index, $ll_form, $fields, $route['map'] + self::suggest( $ll_form['fields'], $fields ) );
}
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 '' . esc_html( $field['label'] ) . ' ';
echo '' . wp_kses( self::icon_svg( 'arrow' ), self::SVG ) . ' ';
echo ' ';
echo '';
echo '' . esc_html__( 'Don\'t send', 'launchlist' ) . ' ';
foreach ( $fields as $source ) {
echo '' . esc_html( self::field_label( $source ) ) . ' ';
}
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 '' . esc_html( self::when( (int) $entry['time'] ) ) . ' ';
}
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 '';
self::render_form_options( $list['forms'], '', true );
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 wp_kses( self::icon_svg( 'copy' ), self::SVG ) . wp_kses( self::icon_svg( 'check', 'll-copied' ), self::SVG );
echo '' . esc_html( $code[1] ) . ' ';
}
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 '' . esc_html( $form['name'] ) . ' '; // 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 '';
}
/**
* 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 '' . self::ICONS[ $name ] . ' ';
}
/**
* 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 );
}
}