From 139375d9efb4a8d045c9b2d6011ae12109264210 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 25 Aug 2026 16:56:54 +0200 Subject: [PATCH 01/45] Init onboarding --- src/class-tiny-onboarding.php | 92 +++++++++++++++++++++++++++++++++++ src/class-tiny-plugin.php | 1 + src/views/onboarding-1.php | 6 +++ tiny-compress-images.php | 1 + 4 files changed, 100 insertions(+) create mode 100644 src/class-tiny-onboarding.php create mode 100644 src/views/onboarding-1.php diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php new file mode 100644 index 00000000..85bd0372 --- /dev/null +++ b/src/class-tiny-onboarding.php @@ -0,0 +1,92 @@ +settings = $settings; + } + + function admin_init() { + // $this->set_is_onboarded( 0 ); + if ( $this->is_new_user() && !$this->is_onboarded() ) { + // if user is new and not onboarded + $this->set_is_onboarded( 1 ); + wp_safe_redirect( admin_url( 'options-general.php?page=' . $this->onboarding_url ) ); + exit(); + } + } + + function admin_menu() { + add_submenu_page( + null, + 'Onboarding TinyPNG', + 'Welcome to TinyPNG', + 'manage_options', + $this->onboarding_url, + array( $this, 'render_onboarding_page'), + ); + } + + function render_onboarding_page() { + include __DIR__ . '/views/onboarding-1.php'; + } + + /** + * Checks if user is 'new' + * + * @return bool true if user has no compressions or api key + */ + function is_new_user() { + $api_key = $this->settings->get_api_key(); + $compression_count = $this->settings->get_compression_count(); + return empty( $api_key ) && empty($compression_count); + } + + function is_onboarded() { + $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); + return get_option( $onboarding_status_field ); + } + + /** + * Sets the onboarding status + * + * @param int $is_onboarded status + */ + function set_is_onboarded( $is_onboarded ) { + $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); + return update_option( $onboarding_status_field, $is_onboarded ); + } +} \ No newline at end of file diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index 4a00d97b..59539323 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -89,6 +89,7 @@ public function init() { ); $this->tiny_compatibility(); + new Tiny_Onboarding( $this->settings ); } public function cli_init() { diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php new file mode 100644 index 00000000..b9bc033a --- /dev/null +++ b/src/views/onboarding-1.php @@ -0,0 +1,6 @@ +
+

Tinify Account

+

Increase performance and save space with the best + compression algorithm in WordPress.

+ settings->render_account_status(); ?> +
\ No newline at end of file diff --git a/tiny-compress-images.php b/tiny-compress-images.php index 168ff18d..a4580711 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -30,6 +30,7 @@ require dirname( __FILE__ ) . '/src/class-tiny-picture.php'; require dirname( __FILE__ ) . '/src/class-tiny-dashboard.php'; require dirname( __FILE__ ) . '/src/class-tiny-apache-rewrite.php'; +require dirname( __FILE__ ) . '/src/class-tiny-onboarding.php'; require dirname( __FILE__ ) . '/src/compatibility/wpml/class-tiny-wpml.php'; require dirname( __FILE__ ) . '/src/compatibility/as3cf/class-tiny-as3cf.php'; require dirname( __FILE__ ) . '/src/compatibility/woocommerce/class-tiny-woocommerce.php'; From 6f0b106257ba84bdd800316677a8d3a6178f74db Mon Sep 17 00:00:00 2001 From: tijmen Date: Thu, 27 Aug 2026 11:40:14 +0200 Subject: [PATCH 02/45] onboarding scaffolding --- src/class-tiny-onboarding.php | 74 +++++++++++++++++++++++++++-------- src/class-tiny-settings.php | 10 +++++ src/css/admin.css | 7 ++++ src/js/admin.js | 12 ++++++ src/views/onboarding-1.php | 6 ++- src/views/onboarding-2.php | 1 + 6 files changed, 91 insertions(+), 19 deletions(-) create mode 100644 src/views/onboarding-2.php diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 85bd0372..01260404 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -24,6 +24,8 @@ class Tiny_Onboarding extends Tiny_WP_Base { private $onboarding_url = 'tiny-onboarding'; + private $steps = array(1, 2); + /** * Tiny settings * @@ -40,28 +42,37 @@ public function __construct( $settings ) { } function admin_init() { - // $this->set_is_onboarded( 0 ); - if ( $this->is_new_user() && !$this->is_onboarded() ) { + if ( $this->is_onboarded() ) { // if user is new and not onboarded $this->set_is_onboarded( 1 ); - wp_safe_redirect( admin_url( 'options-general.php?page=' . $this->onboarding_url ) ); + wp_safe_redirect( $this->get_step_url( 1 ) ); exit(); } } function admin_menu() { - add_submenu_page( - null, - 'Onboarding TinyPNG', - 'Welcome to TinyPNG', - 'manage_options', - $this->onboarding_url, - array( $this, 'render_onboarding_page'), - ); - } - - function render_onboarding_page() { - include __DIR__ . '/views/onboarding-1.php'; + $title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); + + foreach ( $this->steps as $step ) { + $slug = $this->get_step_slug( $step ); + + $hook = add_submenu_page( + 'options-general.php', + $title, + $title, + 'manage_options', + $slug, + function () use ( $step ) { + include __DIR__ . '/views/onboarding-' . $step . '.php'; + } + ); + + if ( ! $hook ) { + continue; + } + + remove_submenu_page( 'options-general.php', $slug ); + } } /** @@ -70,9 +81,9 @@ function render_onboarding_page() { * @return bool true if user has no compressions or api key */ function is_new_user() { - $api_key = $this->settings->get_api_key(); + $has_api_key = $this->settings->has_api_key(); $compression_count = $this->settings->get_compression_count(); - return empty( $api_key ) && empty($compression_count); + return ! $has_api_key && empty($compression_count); } function is_onboarded() { @@ -80,6 +91,26 @@ function is_onboarded() { return get_option( $onboarding_status_field ); } + /** + * Returns the page slug of the given onboarding step + * + * @param int $step + * @return string + */ + private function get_step_slug( $step ) { + return $this->onboarding_url . '-' . $step; + } + + /** + * Retrieves the url of the given onboarding step + * + * @param int $step + * @return string + */ + public function get_step_url( $step ) { + return admin_url( 'options-general.php?page=' . $this->get_step_slug( $step ) ); + } + /** * Sets the onboarding status * @@ -89,4 +120,13 @@ function set_is_onboarded( $is_onboarded ) { $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); return update_option( $onboarding_status_field, $is_onboarded ); } + + function render_register() { + $compressor = $this->settings->get_compressor(); + if ( $compressor->can_create_key() ) { + include __DIR__ . '/views/account-status-create-advanced.php'; + } else { + include __DIR__ . '/views/account-status-create-simple.php'; + } + } } \ No newline at end of file diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 045e38e7..78b4505c 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -236,6 +236,16 @@ protected function get_api_key_pending() { } } + public function has_api_key() { + $api_key = $this->get_api_key(); + if ( empty($api_key) ) { + return false; + } + + $pending_key = $this->get_api_key_pending(); + return !empty( $pending_key ); + } + protected function clear_api_key_pending() { delete_option( self::get_prefixed_name( 'api_key_pending' ) ); } diff --git a/src/css/admin.css b/src/css/admin.css index cfaa00ba..9e94db5d 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -553,4 +553,11 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-icon-backup { flex-shrink: 0; fill: currentcolor; +} + +.tiny-onboarding { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index 9430368e..c0a3ffd1 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -238,6 +238,13 @@ var status = jQuery.parseJSON(json); if (status.ok) { + // when in onboarding + const nextStep = jQuery('#tiny-onboarding-step').data('next-step'); + if (nextStep) { + window.location = nextStep; + return; + } + var target = jQuery('#tiny-account-status'); if (target.length) { jQuery.get(ajaxurl + (ajaxurl.indexOf( '?' ) > 0 ? '&' : '?') + 'action=tiny_account_status', function(data) { @@ -363,6 +370,7 @@ }); } + console.log('adminpage:', adminpage); switch (adminpage) { case 'upload-php': eventOn('click', 'button.tiny-compress', compressImage); @@ -380,6 +388,10 @@ case 'post-php': eventOn('click', 'button.tiny-compress', compressImage); break; + case 'settings_page_tiny-onboarding': + eventOn('click', '[data-tiny-action=create-key]', submitKey); + eventOn('click', '[data-tiny-action=update-key]', submitKey); + break; case 'settings_page_tinify': changeEnterKeyTarget('div.tiny-account-status create', '[data-tiny-action=create-key]'); changeEnterKeyTarget('div.tiny-account-status update', '[data-tiny-action=update-key]'); diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php index b9bc033a..3b3882df 100644 --- a/src/views/onboarding-1.php +++ b/src/views/onboarding-1.php @@ -1,6 +1,8 @@ -
+

Tinify Account

Increase performance and save space with the best compression algorithm in WordPress.

- settings->render_account_status(); ?> +
+ settings->render_account_status(); ?> +
\ No newline at end of file diff --git a/src/views/onboarding-2.php b/src/views/onboarding-2.php new file mode 100644 index 00000000..89435c7c --- /dev/null +++ b/src/views/onboarding-2.php @@ -0,0 +1 @@ +

test

\ No newline at end of file From 83da1bff23c6e37bfcb6782117b27d71e3d5b6c0 Mon Sep 17 00:00:00 2001 From: tijmen Date: Thu, 27 Aug 2026 12:12:48 +0200 Subject: [PATCH 03/45] format --- src/class-tiny-settings.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 78b4505c..054a269c 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -238,12 +238,12 @@ protected function get_api_key_pending() { public function has_api_key() { $api_key = $this->get_api_key(); - if ( empty($api_key) ) { + if ( empty( $api_key ) ) { return false; } $pending_key = $this->get_api_key_pending(); - return !empty( $pending_key ); + return ! empty( $pending_key ); } protected function clear_api_key_pending() { From 9b1d41608c1a02963cfdc7ab530639fa019d60d8 Mon Sep 17 00:00:00 2001 From: tijmen Date: Thu, 27 Aug 2026 12:13:59 +0200 Subject: [PATCH 04/45] use onboarding flag --- src/class-tiny-onboarding.php | 179 +++++++++++++++++----------------- src/views/onboarding-1.php | 12 +-- tiny-compress-images.php | 2 + 3 files changed, 97 insertions(+), 96 deletions(-) diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 01260404..382b863c 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -21,112 +21,111 @@ /** * Class responsible for onboarding a new user */ - class Tiny_Onboarding extends Tiny_WP_Base { +class Tiny_Onboarding extends Tiny_WP_Base { + + private $onboarding_url = 'tiny-onboarding'; + private $steps = array( 1, 2 ); - private $onboarding_url = 'tiny-onboarding'; - private $steps = array(1, 2); - /** * Tiny settings * * @var Tiny_Settings */ - private $settings; + private $settings; /** * @param Tiny_Settings $settings */ - public function __construct( $settings ) { - parent::__construct(); - $this->settings = $settings; - } - - function admin_init() { - if ( $this->is_onboarded() ) { - // if user is new and not onboarded - $this->set_is_onboarded( 1 ); - wp_safe_redirect( $this->get_step_url( 1 ) ); - exit(); - } - } - - function admin_menu() { - $title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); - - foreach ( $this->steps as $step ) { - $slug = $this->get_step_slug( $step ); - - $hook = add_submenu_page( - 'options-general.php', - $title, - $title, - 'manage_options', - $slug, - function () use ( $step ) { - include __DIR__ . '/views/onboarding-' . $step . '.php'; - } - ); - - if ( ! $hook ) { - continue; - } - - remove_submenu_page( 'options-general.php', $slug ); - } - } + public function __construct( $settings ) { + parent::__construct(); + $this->settings = $settings; + } + + function admin_init() { + if ( $this->is_onboarded() ) { + // if user is not onboarded + $this->set_is_onboarded( 1 ); + wp_safe_redirect( $this->get_step_url( 1 ) ); + exit(); + } + } + + function admin_menu() { + $title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); + + foreach ( $this->steps as $step ) { + $slug = $this->get_step_slug( $step ); + + $hook = add_submenu_page( + 'options-general.php', + $title, + $title, + 'manage_options', + $slug, + function () use ( $step ) { + include __DIR__ . '/views/onboarding-' . $step . '.php'; + } + ); + + if ( ! $hook ) { + continue; + } + + remove_submenu_page( 'options-general.php', $slug ); + } + } /** - * Checks if user is 'new' + * Checks wether user is onboarded + * defaults to true * - * @return bool true if user has no compressions or api key + * @return boolean true if onboarded */ - function is_new_user() { - $has_api_key = $this->settings->has_api_key(); - $compression_count = $this->settings->get_compression_count(); - return ! $has_api_key && empty($compression_count); - } - - function is_onboarded() { - $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); - return get_option( $onboarding_status_field ); - } - - /** - * Returns the page slug of the given onboarding step - * - * @param int $step - * @return string - */ - private function get_step_slug( $step ) { - return $this->onboarding_url . '-' . $step; - } - - /** - * Retrieves the url of the given onboarding step - * - * @param int $step - * @return string - */ - public function get_step_url( $step ) { - return admin_url( 'options-general.php?page=' . $this->get_step_slug( $step ) ); - } + function is_onboarded() { + $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); + return 1 === get_option( $onboarding_status_field, 1 ); + } + + /** + * Returns the page slug of the given onboarding step + * + * @param int $step + * @return string + */ + private function get_step_slug( $step ) { + return $this->onboarding_url . '-' . $step; + } + + /** + * Retrieves the url of the given onboarding step + * + * @param int $step + * @return string + */ + public function get_step_url( $step ) { + return admin_url( 'options-general.php?page=' . $this->get_step_slug( $step ) ); + } /** * Sets the onboarding status * - * @param int $is_onboarded status + * @param int $is_onboarded status */ - function set_is_onboarded( $is_onboarded ) { - $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); - return update_option( $onboarding_status_field, $is_onboarded ); - } - - function render_register() { - $compressor = $this->settings->get_compressor(); - if ( $compressor->can_create_key() ) { - include __DIR__ . '/views/account-status-create-advanced.php'; - } else { - include __DIR__ . '/views/account-status-create-simple.php'; - } - } -} \ No newline at end of file + static function set_is_onboarded( $is_onboarded ) { + $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); + return update_option( $onboarding_status_field, $is_onboarded ); + } + + function render_register() { + $compressor = $this->settings->get_compressor(); + if ( $compressor->can_create_key() ) { + include __DIR__ . '/views/account-status-create-advanced.php'; + } else { + include __DIR__ . '/views/account-status-create-simple.php'; + } + } + + static function on_activate() { + self::set_is_onboarded( 0 ); + } +} diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php index 3b3882df..d25e4ce9 100644 --- a/src/views/onboarding-1.php +++ b/src/views/onboarding-1.php @@ -1,8 +1,8 @@
-

Tinify Account

-

Increase performance and save space with the best - compression algorithm in WordPress.

-
- settings->render_account_status(); ?> -
+

Tinify Account

+

Increase performance and save space with the best + compression algorithm in WordPress.

+
+ settings->render_account_status(); ?> +
\ No newline at end of file diff --git a/tiny-compress-images.php b/tiny-compress-images.php index a4580711..67f47919 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -49,3 +49,5 @@ __FILE__, array( 'Tiny_Plugin', 'uninstall' ) ); + +register_activation_hook(__FILE__, array('Tiny_Onboarding', 'on_activate') ); \ No newline at end of file From cea9bcb8452bc3305616a0432cb9264998db629b Mon Sep 17 00:00:00 2001 From: tijmen Date: Thu, 27 Aug 2026 13:18:17 +0200 Subject: [PATCH 05/45] transport to step 2 on api key registration --- src/class-tiny-onboarding.php | 24 ++++++++++++++++++------ src/js/admin.js | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 382b863c..48cc29bc 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -43,11 +43,12 @@ public function __construct( $settings ) { function admin_init() { if ( $this->is_onboarded() ) { - // if user is not onboarded - $this->set_is_onboarded( 1 ); - wp_safe_redirect( $this->get_step_url( 1 ) ); - exit(); + return; } + + $this->set_is_onboarded( 1 ); + wp_safe_redirect( $this->get_step_url( 1 ) ); + exit(); } function admin_menu() { @@ -83,7 +84,7 @@ function () use ( $step ) { */ function is_onboarded() { $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); - return 1 === get_option( $onboarding_status_field, 1 ); + return 1 === (int) get_option( $onboarding_status_field, 1 ); } /** @@ -125,7 +126,18 @@ function render_register() { } } + /** + * Decides on activation whether this site still needs onboarding. + * + * A site that already has a key, or that has compressed before, keeps the + * default onboarded state so it is never sent through onboarding again. + */ static function on_activate() { - self::set_is_onboarded( 0 ); + $api_key = get_option( self::get_prefixed_name( 'api_key' ) ); + $compression_count = get_option( self::get_prefixed_name( 'status' ) ); + + if ( empty( $api_key ) && empty( $compression_count ) ) { + self::set_is_onboarded( 0 ); + } } } diff --git a/src/js/admin.js b/src/js/admin.js index c0a3ffd1..fb683d64 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -388,7 +388,7 @@ case 'post-php': eventOn('click', 'button.tiny-compress', compressImage); break; - case 'settings_page_tiny-onboarding': + case 'settings_page_tiny-onboarding-1': eventOn('click', '[data-tiny-action=create-key]', submitKey); eventOn('click', '[data-tiny-action=update-key]', submitKey); break; From a334aa21becfd5ff47f1b29a2c20736779b7ae78 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 1 Sep 2026 17:33:50 +0200 Subject: [PATCH 06/45] onboarding --- src/class-tiny-onboarding.php | 63 +++++++++++++++++++++++++++++---- src/class-tiny-settings.php | 2 +- src/css/admin.css | 10 ++++++ src/js/admin.js | 30 ++++++++++++---- src/views/onboarding-1.php | 22 +++++++++--- src/views/onboarding-2.php | 65 ++++++++++++++++++++++++++++++++++- 6 files changed, 172 insertions(+), 20 deletions(-) diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 48cc29bc..97887def 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -23,8 +23,17 @@ */ class Tiny_Onboarding extends Tiny_WP_Base { - private $onboarding_url = 'tiny-onboarding'; - private $steps = array( 1, 2 ); + /** + * Prefix of every onboarding step page slug. + * + * @var string + */ + const PAGE_SLUG = 'tiny-onboarding'; + + /** + * @var string + */ + private $page_title; /** * Tiny settings @@ -47,20 +56,34 @@ function admin_init() { } $this->set_is_onboarded( 1 ); + + if ( self::is_bulk_activation() ) { + return; + } + wp_safe_redirect( $this->get_step_url( 1 ) ); exit(); } + /** + * Onboarding is skipped when activating multiple plugins + * + * @return bool + */ + private static function is_bulk_activation() { + return filter_has_var( INPUT_GET, 'activate-multi' ); + } + function admin_menu() { - $title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); + $this->page_title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); foreach ( $this->steps as $step ) { $slug = $this->get_step_slug( $step ); $hook = add_submenu_page( 'options-general.php', - $title, - $title, + $this->page_title, + $this->page_title, 'manage_options', $slug, function () use ( $step ) { @@ -73,9 +96,24 @@ function () use ( $step ) { } remove_submenu_page( 'options-general.php', $slug ); + + /** + * because title is retrieved from submenu, which is not part of the menu, + * resolve it through globals + */ + add_action( 'load-' . $hook, $this->get_method( 'set_page_title' ) ); } } + /** + * Supplies the title of a page that is not listed in the menu. + * + * Hooked to `load-{$hook}` of every onboarding step. + */ + public function set_page_title() { + $GLOBALS['title'] = $this->page_title; + } + /** * Checks wether user is onboarded * defaults to true @@ -94,7 +132,20 @@ function is_onboarded() { * @return string */ private function get_step_slug( $step ) { - return $this->onboarding_url . '-' . $step; + return self::PAGE_SLUG . '-' . $step; + } + + /** + * Whether the current admin request is one of the onboarding steps. + * + * Reads the page WordPress itself resolved, so no request input is touched. + * + * @return bool + */ + public static function is_onboarding_page() { + $page = isset( $GLOBALS['plugin_page'] ) ? $GLOBALS['plugin_page'] : ''; + + return 0 === strpos( $page, self::PAGE_SLUG . '-' ); } /** diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 054a269c..99b2a8d3 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -108,7 +108,7 @@ public function admin_init() { ); } - if ( current_user_can( 'manage_options' ) ) { + if ( current_user_can( 'manage_options' ) && ! Tiny_Onboarding::is_onboarding_page() ) { $this->setup_incomplete_checks(); } diff --git a/src/css/admin.css b/src/css/admin.css index 9e94db5d..60ffb1be 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -560,4 +560,14 @@ fieldset.tinypng_convert_fields[disabled] { flex-direction: column; justify-content: center; align-items: center; +} + +.tiny-card { + background: #fff; + border: 1px solid #E1E1E1; + border-radius: 8px; + padding: 12px; +} +.tiny-text-center { + text-align: center; } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index fb683d64..e3b9e7a4 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -203,6 +203,22 @@ return false; } + /** + * Does nothing outside of onboarding + * If key is active, shows continue button + */ + function updateOnboardingContinue() { + const button = jQuery('#tiny-onboarding-continue'); + if (!button.length) { + return; + } + + const status = jQuery('#tiny-account-status p.status').closest('div.status'); + const valid = status.hasClass('status-success') || status.hasClass('status-pending'); + + button.toggle(valid); + } + function submitKey(event) { event.preventDefault(); jQuery(event.target).attr({disabled: true}).addClass('loading'); @@ -238,18 +254,13 @@ var status = jQuery.parseJSON(json); if (status.ok) { - // when in onboarding - const nextStep = jQuery('#tiny-onboarding-step').data('next-step'); - if (nextStep) { - window.location = nextStep; - return; - } - var target = jQuery('#tiny-account-status'); if (target.length) { jQuery.get(ajaxurl + (ajaxurl.indexOf( '?' ) > 0 ? '&' : '?') + 'action=tiny_account_status', function(data) { jQuery(event.target).attr({disabled: false}).removeClass('loading'); target.replaceWith(data); + // The refreshed markup reports whether the key actually works. + updateOnboardingContinue(); }); } jQuery('div.tiny-notice[data-name="setting"]').remove(); @@ -389,8 +400,13 @@ eventOn('click', 'button.tiny-compress', compressImage); break; case 'settings_page_tiny-onboarding-1': + changeEnterKeyTarget('div.tiny-account-status create', '[data-tiny-action=create-key]'); + changeEnterKeyTarget('div.tiny-account-status update', '[data-tiny-action=update-key]'); + eventOn('click', '[data-tiny-action=create-key]', submitKey); eventOn('click', '[data-tiny-action=update-key]', submitKey); + + updateOnboardingContinue(); break; case 'settings_page_tinify': changeEnterKeyTarget('div.tiny-account-status create', '[data-tiny-action=create-key]'); diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php index d25e4ce9..c6b8fa3c 100644 --- a/src/views/onboarding-1.php +++ b/src/views/onboarding-1.php @@ -1,8 +1,20 @@
-

Tinify Account

-

Increase performance and save space with the best - compression algorithm in WordPress.

-
+

+

+ +

+
settings->render_account_status(); ?>
-
\ No newline at end of file + +
diff --git a/src/views/onboarding-2.php b/src/views/onboarding-2.php index 89435c7c..c933515f 100644 --- a/src/views/onboarding-2.php +++ b/src/views/onboarding-2.php @@ -1 +1,64 @@ -

test

\ No newline at end of file + + +
+

+

+ +
+ +

+ +
+

+ + > + +

+

+ + > + +

+
+ +

+ + + +

+

+ + + +

+
From 18f572bc1d33abcbf72da52ff15bc7abb6eda591 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 8 Sep 2026 12:48:59 +0200 Subject: [PATCH 07/45] simplify menu registration --- src/class-tiny-onboarding.php | 60 ++++++++++++++++++++--------------- src/js/admin.js | 1 - 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 97887def..36674ada 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -74,35 +74,43 @@ private static function is_bulk_activation() { return filter_has_var( INPUT_GET, 'activate-multi' ); } - function admin_menu() { - $this->page_title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); - - foreach ( $this->steps as $step ) { - $slug = $this->get_step_slug( $step ); - - $hook = add_submenu_page( - 'options-general.php', - $this->page_title, - $this->page_title, - 'manage_options', - $slug, - function () use ( $step ) { - include __DIR__ . '/views/onboarding-' . $step . '.php'; - } - ); - - if ( ! $hook ) { - continue; + /** + * Set the sub menu pages + * + * @param int $step onboarding step + */ + private function add_sub_menu( $step ) { + $slug = $this->get_step_slug( $step ); + + $hook = add_submenu_page( + 'options-general.php', + $this->page_title, + $this->page_title, + 'manage_options', + $slug, + function () use ( $step ) { + include __DIR__ . '/views/onboarding-' . $step . '.php'; } + ); - remove_submenu_page( 'options-general.php', $slug ); - - /** - * because title is retrieved from submenu, which is not part of the menu, - * resolve it through globals - */ - add_action( 'load-' . $hook, $this->get_method( 'set_page_title' ) ); + if ( ! $hook ) { + return; } + + remove_submenu_page( 'options-general.php', $slug ); + + /** + * because title is retrieved from submenu, which is not part of the menu, + * resolve it through globals + */ + add_action( 'load-' . $hook, $this->get_method( 'set_page_title' ) ); + } + + function admin_menu() { + $this->page_title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); + + $this->add_sub_menu(1); + $this->add_sub_menu(2); } /** diff --git a/src/js/admin.js b/src/js/admin.js index e3b9e7a4..a82b72c3 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -381,7 +381,6 @@ }); } - console.log('adminpage:', adminpage); switch (adminpage) { case 'upload-php': eventOn('click', 'button.tiny-compress', compressImage); From 235934c0f96070fb82702f1c8a9df55b77f4455b Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 12 Oct 2026 15:07:41 +0200 Subject: [PATCH 08/45] add media assets --- src/images/alert-circle.svg | 1 + src/images/panda-juggle.png | Bin 0 -> 33513 bytes src/images/panda-thumbs-up.png | Bin 0 -> 27520 bytes src/images/panda-unbox.png | Bin 0 -> 46063 bytes 4 files changed, 1 insertion(+) create mode 100644 src/images/alert-circle.svg create mode 100644 src/images/panda-juggle.png create mode 100644 src/images/panda-thumbs-up.png create mode 100644 src/images/panda-unbox.png diff --git a/src/images/alert-circle.svg b/src/images/alert-circle.svg new file mode 100644 index 00000000..9a6ad929 --- /dev/null +++ b/src/images/alert-circle.svg @@ -0,0 +1 @@ + diff --git a/src/images/panda-juggle.png b/src/images/panda-juggle.png new file mode 100644 index 0000000000000000000000000000000000000000..9a1b5dbbe36a2c2b61f9d9761594fd5e1fce7aef GIT binary patch literal 33513 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 literal 0 HcmV?d00001 diff --git a/src/images/panda-thumbs-up.png b/src/images/panda-thumbs-up.png new file mode 100644 index 0000000000000000000000000000000000000000..66b5b74f37eb0dcc8da19e17775e1bb6d14eb506 GIT binary patch literal 27520 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 Y50~p1HZ4XK{{R3007*qoM6N<$f>C_#FaQ7m literal 0 HcmV?d00001 diff --git a/src/images/panda-unbox.png b/src/images/panda-unbox.png new file mode 100644 index 0000000000000000000000000000000000000000..1493639a5b723a18d351f285a94d7d66bfb564a5 GIT binary patch literal 46063 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 literal 0 HcmV?d00001 From da4557db104fbba7fc5938ae4d641b3c0b3a4bd6 Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 12 Oct 2026 16:10:58 +0200 Subject: [PATCH 09/45] UI for onboarding --- src/class-tiny-onboarding.php | 75 ++++- src/class-tiny-settings.php | 52 ++-- src/css/admin.css | 400 ++++++++++++++++++++++++++- src/js/admin.js | 113 ++++++-- src/views/account-status-connect.php | 95 +++++++ src/views/onboarding-1.php | 35 ++- src/views/onboarding-2.php | 89 +++--- src/views/onboarding-3.php | 194 +++++++++++++ src/views/onboarding-steps.php | 44 +++ test/unit/TinySettingsAdminTest.php | 2 - 10 files changed, 976 insertions(+), 123 deletions(-) create mode 100644 src/views/account-status-connect.php create mode 100644 src/views/onboarding-3.php create mode 100644 src/views/onboarding-steps.php diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 36674ada..b70f7f54 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -51,6 +51,11 @@ public function __construct( $settings ) { } function admin_init() { + add_action( + 'admin_post_tiny_onboarding_save_settings', + $this->get_method( 'save_settings' ) + ); + if ( $this->is_onboarded() ) { return; } @@ -109,8 +114,9 @@ function () use ( $step ) { function admin_menu() { $this->page_title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); - $this->add_sub_menu(1); - $this->add_sub_menu(2); + $this->add_sub_menu( 1 ); + $this->add_sub_menu( 2 ); + $this->add_sub_menu( 3 ); } /** @@ -176,13 +182,68 @@ static function set_is_onboarded( $is_onboarded ) { return update_option( $onboarding_status_field, $is_onboarded ); } - function render_register() { - $compressor = $this->settings->get_compressor(); - if ( $compressor->can_create_key() ) { - include __DIR__ . '/views/account-status-create-advanced.php'; + /** + * Renders the onboarding progress with the given step as current. + * + * @param int $current_step + * @param bool $confirm_email false when connecting with an API key skipped that step + */ + public function render_steps( $current_step, $confirm_email = true ) { + include __DIR__ . '/views/onboarding-steps.php'; + } + + /** + * Saves settings from onboading page, + * redirects to bulk optimizer. + * + * Hooked to `admin_post_tiny_onboarding_save_settings`. + */ + public function save_settings() { + check_admin_referer( 'tiny-onboarding-save-settings' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( + 'Sorry, you are not allowed to manage options for this site.', + 'tiny-compress-images' + ) + ); + } + + $convert_to = isset( $_POST['convert_to'] ) ? + sanitize_key( wp_unslash( $_POST['convert_to'] ) ) : ''; + if ( ! in_array( $convert_to, array( 'smallest', 'webp', 'avif' ), true ) ) { + $convert_to = 'smallest'; + } + + $delivery_method = isset( $_POST['delivery_method'] ) ? + sanitize_key( wp_unslash( $_POST['delivery_method'] ) ) : ''; + if ( ! in_array( $delivery_method, array( 'htaccess', 'picture' ), true ) ) { + $delivery_method = 'picture'; + } + + $convert_format = array( 'convert' => 'off' ); + if ( isset( $_POST['convert'] ) ) { + $convert_format = array( + 'convert' => 'on', + 'convert_to' => $convert_to, + 'delivery_method' => $delivery_method, + ); + } + + update_option( + self::get_prefixed_name( 'compression_timing' ), + isset( $_POST['auto_compress'] ) ? 'background' : 'manual' + ); + update_option( self::get_prefixed_name( 'convert_format' ), $convert_format ); + + $next = isset( $_POST['next'] ) ? sanitize_key( wp_unslash( $_POST['next'] ) ) : ''; + if ( 'settings' === $next ) { + wp_safe_redirect( admin_url( 'options-general.php?page=tinify' ) ); } else { - include __DIR__ . '/views/account-status-create-simple.php'; + wp_safe_redirect( admin_url( 'upload.php?page=tiny-bulk-optimization' ) ); } + exit(); } /** diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 99b2a8d3..1ee516cf 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -236,18 +236,27 @@ protected function get_api_key_pending() { } } - public function has_api_key() { - $api_key = $this->get_api_key(); - if ( empty( $api_key ) ) { + protected function clear_api_key_pending() { + delete_option( self::get_prefixed_name( 'api_key_pending' ) ); + } + + /** + * Whether the API key of a new account still waits for its email to be + * confirmed. While pending, asks the API and clears it once the key works. + * + * @return bool + */ + public function is_api_key_pending() { + if ( ! $this->get_api_key_pending() ) { return false; } - $pending_key = $this->get_api_key_pending(); - return ! empty( $pending_key ); - } + if ( $this->compressor->get_status()->ok ) { + $this->clear_api_key_pending(); + return false; + } - protected function clear_api_key_pending() { - delete_option( self::get_prefixed_name( 'api_key_pending' ) ); + return true; } /** @@ -895,6 +904,17 @@ public function render_account_status() { } } + /** + * Renders the form to create a Tinify account or to connect one with an API key. + */ + public function render_account_connect() { + $can_create_key = $this->get_compressor()->can_create_key(); + $user = wp_get_current_user(); + $name = trim( $user->user_firstname . ' ' . $user->user_lastname ); + $email = trim( $user->user_email ); + include __DIR__ . '/views/account-status-connect.php'; + } + public function render_pending_status() { $key = $this->get_api_key(); if ( empty( $key ) ) { @@ -944,6 +964,8 @@ public function create_api_key() { } try { + $name = sanitize_text_field( wp_unslash( $_POST['name'] ) ); + $email = sanitize_email( wp_unslash( $_POST['email'] ) ); $site = str_replace( array( 'http://', 'https://' ), '', @@ -951,18 +973,16 @@ public function create_api_key() { ); $identifier = 'WordPress plugin for ' . $site; $link = $this->get_absolute_url(); - $compressor->create_key( - sanitize_email( wp_unslash( $_POST['email'] ) ), - array( - 'name' => sanitize_text_field( wp_unslash( $_POST['name'] ) ), - 'identifier' => $identifier, - 'link' => $link, - ) + $options = array( + 'name' => $name, + 'identifier' => $identifier, ); + $compressor->create_key( $email, $options ); update_option( self::get_prefixed_name( 'api_key_pending' ), true ); update_option( self::get_prefixed_name( 'api_key' ), $compressor->get_key() ); update_option( self::get_prefixed_name( 'status' ), 0 ); + update_option( self::get_prefixed_name( 'email_address' ), $email ); $status = (object) array( 'ok' => true, @@ -1060,7 +1080,7 @@ private static function render_radiobutton( /** * @return bool true if apache with mod_rewrite loaded */ - private static function can_render_delivery_method() { + public static function can_render_delivery_method() { global $is_apache; if ( ! $is_apache ) { return false; diff --git a/src/css/admin.css b/src/css/admin.css index 60ffb1be..cc0990f7 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -555,19 +555,409 @@ fieldset.tinypng_convert_fields[disabled] { fill: currentcolor; } +/* Account connect */ +.tiny-account-connect { + box-sizing: border-box; + display: flex; + width: 100%; + background: #fff; + border: 1px solid #ededf1; + border-radius: 12px; +} + +.tiny-account-connect-column { + display: flex; + flex: 1 1 0%; + flex-direction: column; + gap: 16px; + min-width: 0; + padding: 24px; +} + +.tiny-account-connect-column + .tiny-account-connect-column { + border-left: 1px solid #ededf1; +} + +@media screen and (max-width: 600px) { + .tiny-account-connect { + flex-direction: column; + } + + .tiny-account-connect-column + .tiny-account-connect-column { + border-top: 1px solid #ededf1; + border-left: 0; + } +} + +.tiny-card-head h2 { + margin: 0; + padding: 0; + color: #12141d; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.tiny-card-head p { + margin: 4px 0 0; + color: #5b5b67; + font-size: 14px; + line-height: 20px; +} + +.tiny-field { + margin: 0; +} + +.tiny-field label { + display: block; + margin-bottom: 6px; + color: #4a4b56; + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.tiny-field input[type="text"] { + box-sizing: border-box; + width: 100%; + height: auto; + margin: 0; + padding: 9px 14px; + color: #1e2029; + font-size: 16px; + line-height: 24px; + background-color: #fff; + border: 1px solid #b8b8c1; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(18, 20, 29, 0.05); +} + +.tiny-field input[type="text"]::placeholder { + color: #7a7a86; +} + +.tiny-field input[type="text"]:focus { + border-color: var(--wp-admin-theme-color, #2271b1); + box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #2271b1); + outline: 2px solid transparent; +} + +.tiny-field-invalid input[type="text"] { + padding-right: 38px; + background: #fff url("../images/alert-circle.svg") no-repeat right 14px center; + border-color: #fca5a5; +} + +.tiny-field-invalid input[type="text"]:focus { + border-color: #fca5a5; + box-shadow: 0 0 0 1px #fca5a5; +} + +.tiny-field-error { + display: none; + margin: 6px 0 0; + color: #ef4444; + font-size: 14px; + line-height: 20px; +} + +.tiny-field-error a { + color: inherit; +} + +.tiny-field-invalid .tiny-field-error { + display: block; +} + +.tiny-actions { + display: flex; + align-items: center; + gap: 16px; + margin: 0; +} + +.tiny-actions .button { + height: auto; + min-height: 40px; + padding: 9px 16px; + font-size: 14px; + font-weight: 600; + line-height: 20px; + border-radius: 2px; +} + +.tiny-actions a, +.tiny-actions .button-link { + color: var(--wp-admin-theme-color, #2271b1); + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-decoration: none; +} + +.tiny-actions .spinner { + float: none; + margin: 0; +} + +/* Onboarding */ .tiny-onboarding { display: flex; flex-direction: column; + align-items: center; + gap: 24px; + max-width: 720px; + /* Matches #wpcontent's left padding, so the page centers between menu and window edge. */ + margin: 64px auto 0; + padding-right: 20px; +} + +.tiny-onboarding-steps { + display: flex; + flex-wrap: wrap; justify-content: center; align-items: center; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.tiny-onboarding-steps li { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: #5b5b67; + font-size: 14px; + line-height: 20px; } -.tiny-card { +/* The connector to the previous step. */ +.tiny-onboarding-steps li + li:before { + content: ""; + width: 40px; + height: 2px; + margin-right: 4px; + background: #d4d4db; + border-radius: 1px; +} + +.tiny-onboarding-steps li.tiny-step-done + li:before { + background: var(--wp-admin-theme-color, #2271b1); +} + +@media screen and (max-width: 600px) { + .tiny-onboarding-steps li + li:before { + display: none; + } +} + +.tiny-step-dot { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + color: #5b5b67; + font-size: 12px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.12px; background: #fff; - border: 1px solid #E1E1E1; - border-radius: 8px; - padding: 12px; + border: 1px solid #ededf1; + border-radius: 12px; +} + +.tiny-step-current { + color: #12141d; + font-weight: 600; +} + +.tiny-step-current .tiny-step-dot { + color: #fff; + background: var(--wp-admin-theme-color, #2271b1); + border: 0; +} + +.tiny-step-done .tiny-step-dot { + color: var(--wp-admin-theme-color, #2271b1); + background: none; + border: 0; +} + +.tiny-onboarding-panda { + display: block; + width: 112px; + height: 112px; } -.tiny-text-center { + +.tiny-onboarding-header { text-align: center; +} + +.tiny-onboarding-header h1 { + margin: 0 0 8px; + padding: 0; + color: #12141d; + font-size: 24px; + font-weight: 600; + line-height: 32px; +} + +.tiny-onboarding-header p { + margin: 0; + color: #5b5b67; + font-size: 14px; + line-height: 20px; +} + +.tiny-onboarding-card { + box-sizing: border-box; + width: 100%; + background: #fff; + border: 1px solid #ededf1; + border-radius: 12px; +} + +.tiny-card-section { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; +} + +.tiny-card-section + .tiny-card-section { + border-top: 1px solid #ededf1; +} + +.tiny-card-section .notice { + margin: 0; +} + +.tiny-option { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0; + cursor: pointer; +} + +.tiny-option-text { + display: flex; + flex-direction: column; + gap: 2px; + color: #5b5b67; + font-size: 16px; + line-height: 24px; +} + +.tiny-option-label { + color: #4a4b56; + font-weight: 500; +} + +/* Toggle switch, drawn over WordPress' own checkbox styles. */ +.tiny-option input[type="checkbox"] { + box-sizing: border-box; + position: relative; + flex: none; + width: 44px; + min-width: 44px; + height: 24px; + margin: 0; + background: #ededf1; + border: 0; + border-radius: 12px; + box-shadow: none; + transition: background-color 0.15s; +} + +.tiny-option input[type="checkbox"]:before, +.tiny-option input[type="checkbox"]:checked:before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + float: none; + width: 20px; + height: 20px; + margin: 0; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(18, 20, 29, 0.06); + transition: transform 0.15s; +} + +.tiny-option input[type="checkbox"]:checked { + background: var(--wp-admin-theme-color, #2271b1); +} + +.tiny-option input[type="checkbox"]:checked:before { + transform: translateX(20px); +} + +/* Radio button, drawn over WordPress' own radio styles. */ +.tiny-option input[type="radio"] { + box-sizing: border-box; + flex: none; + width: 20px; + min-width: 20px; + height: 20px; + margin: 2px 0 0; + background: #fff; + border: 1px solid #b8b8c1; + border-radius: 50%; + box-shadow: none; +} + +.tiny-option input[type="radio"]:checked { + background: var(--wp-admin-theme-color, #2271b1); + border-color: var(--wp-admin-theme-color, #2271b1); +} + +.tiny-option input[type="radio"]:checked:before { + content: ""; + display: block; + float: none; + width: 8px; + height: 8px; + margin: 5px; + text-indent: 0; + background: #fff; + border-radius: 50%; +} + +.tiny-option input[type="checkbox"]:focus, +.tiny-option input[type="radio"]:focus { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--wp-admin-theme-color, #2271b1); + outline: 2px solid transparent; +} + +.tiny-format-options { + display: flex; + flex-direction: column; + gap: 16px; + padding-left: 52px; +} + +.tiny-format-options[hidden] { + display: none; +} + +.tiny-option-group { + margin: 0; + color: #12141d; + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +.tiny-format-note { + margin: 0; + color: #5b5b67; + font-size: 14px; + line-height: 20px; } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index a82b72c3..1e818d8e 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -204,19 +204,92 @@ } /** - * Does nothing outside of onboarding - * If key is active, shows continue button + * Creates an account or connects an API key from the account connect view, + * then opens the onboarding step in the page's data-create-key-next or + * data-update-key-next. A failure is shown under the field it is about. */ - function updateOnboardingContinue() { - const button = jQuery('#tiny-onboarding-continue'); - if (!button.length) { + async function submitAccountConnect(event) { + event.preventDefault(); + + const button = event.target; + const column = button.closest('.tiny-account-connect-column'); + const spinner = column.querySelector('.spinner'); + const action = button.dataset.tinyAction === 'create-key' ? 'create' : 'update'; + const name = document.querySelector('#tinypng_api_key_name')?.value ?? ''; + const email = document.querySelector('#tinypng_api_key_email')?.value ?? ''; + const key = document.querySelector('#tinypng_api_key').value; + let field; + + if (action === 'update') { + field = document.querySelector('#tinypng_api_key').closest('.tiny-field'); + } else if (!name) { + // The server only rejects the name when it is missing. + field = document.querySelector('#tinypng_api_key_name').closest('.tiny-field'); + } else { + field = document.querySelector('#tinypng_api_key_email').closest('.tiny-field'); + } + + column.querySelectorAll('.tiny-field').forEach((f) => f.classList.remove('tiny-field-invalid')); + + // The server saves a blank key as "no key", which is not a connection. + if (action === 'update' && !key) { + field.classList.add('tiny-field-invalid'); return; } - const status = jQuery('#tiny-account-status p.status').closest('div.status'); - const valid = status.hasClass('status-success') || status.hasClass('status-pending'); + const showError = (message) => { + button.disabled = false; + spinner.classList.remove('is-active'); + // The API key field keeps the invalid key message from the view. + if (action === 'create') { + field.querySelector('.tiny-field-error').textContent = message; + } + field.classList.add('tiny-field-invalid'); + }; + + button.disabled = true; + spinner.classList.add('is-active'); + + try { + const response = await fetch(ajaxurl, { + method: 'POST', + body: new URLSearchParams({ + _nonce: tinyCompress.nonce, + action: `tiny_settings_${action}_api_key`, + key, + name, + email, + }), + }); + if (!response.ok) { + throw new Error(response.statusText); + } + const status = await response.json(); + if (status.ok) { + window.location.href = document.querySelector('.tiny-onboarding').dataset[`${action}KeyNext`]; + } else { + showError(status.message); + } + } catch (err) { + showError('Something went wrong, try again soon'); + } + } - button.toggle(valid); + /** + * Opens the next onboarding step once the account is activated from the email. + * The account status markup has status-success once the key works. + */ + async function continueWhenActivated() { + const separator = ajaxurl.includes('?') ? '&' : '?'; + try { + const html = await fetch(`${ajaxurl}${separator}action=tiny_account_status`).then(r => r.text()); + const status = new DOMParser().parseFromString(html, 'text/html'); + if (status.querySelector('.status-success')) { + window.location.href = document.querySelector('.tiny-onboarding').dataset.activatedNext; + } + } finally { + setTimeout(continueWhenActivated, 5000); + } } function submitKey(event) { @@ -259,8 +332,6 @@ jQuery.get(ajaxurl + (ajaxurl.indexOf( '?' ) > 0 ? '&' : '?') + 'action=tiny_account_status', function(data) { jQuery(event.target).attr({disabled: false}).removeClass('loading'); target.replaceWith(data); - // The refreshed markup reports whether the key actually works. - updateOnboardingContinue(); }); } jQuery('div.tiny-notice[data-name="setting"]').remove(); @@ -399,17 +470,23 @@ eventOn('click', 'button.tiny-compress', compressImage); break; case 'settings_page_tiny-onboarding-1': - changeEnterKeyTarget('div.tiny-account-status create', '[data-tiny-action=create-key]'); - changeEnterKeyTarget('div.tiny-account-status update', '[data-tiny-action=update-key]'); + changeEnterKeyTarget('#tinypng_api_key_name, #tinypng_api_key_email', '[data-tiny-action=create-key]'); + changeEnterKeyTarget('#tinypng_api_key', '[data-tiny-action=update-key]'); - eventOn('click', '[data-tiny-action=create-key]', submitKey); - eventOn('click', '[data-tiny-action=update-key]', submitKey); - - updateOnboardingContinue(); + eventOn('click', '[data-tiny-action=create-key]', submitAccountConnect); + eventOn('click', '[data-tiny-action=update-key]', submitAccountConnect); + break; + case 'settings_page_tiny-onboarding-2': + setTimeout(continueWhenActivated, 5000); + break; + case 'settings_page_tiny-onboarding-3': + eventOn('change', '#tiny-convert', (event) => { + document.querySelector('#tiny-format-options').hidden = !event.target.checked; + }); break; case 'settings_page_tinify': - changeEnterKeyTarget('div.tiny-account-status create', '[data-tiny-action=create-key]'); - changeEnterKeyTarget('div.tiny-account-status update', '[data-tiny-action=update-key]'); + changeEnterKeyTarget('div.tiny-account-status div.create', '[data-tiny-action=create-key]'); + changeEnterKeyTarget('div.tiny-account-status div.update', '[data-tiny-action=update-key]'); eventOn('click', '[data-tiny-action=create-key]', submitKey); eventOn('click', '[data-tiny-action=update-key]', submitKey); diff --git a/src/views/account-status-connect.php b/src/views/account-status-connect.php new file mode 100644 index 00000000..0aa338ba --- /dev/null +++ b/src/views/account-status-connect.php @@ -0,0 +1,95 @@ +%s', + esc_html__( 'API dashboard', 'tiny-compress-images' ) +); +?> + diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php index c6b8fa3c..4fca6b31 100644 --- a/src/views/onboarding-1.php +++ b/src/views/onboarding-1.php @@ -1,20 +1,19 @@ -
-

-

- -

-
- settings->render_account_status(); ?> +
+ render_steps( 1 ); ?> + +
+

+

+ +

- + settings->render_account_connect(); ?>
diff --git a/src/views/onboarding-2.php b/src/views/onboarding-2.php index c933515f..e3f791ec 100644 --- a/src/views/onboarding-2.php +++ b/src/views/onboarding-2.php @@ -1,64 +1,39 @@ -
-

-

- -
- -

- -
-

- - > - -

-

- - > - +

+ render_steps( 2 ); ?> + +
+

+

+

-

- - - -

-

- - - -

+
+
+
+

+

+
+

+ + + + + + +

+
+
diff --git a/src/views/onboarding-3.php b/src/views/onboarding-3.php new file mode 100644 index 00000000..cec0fa49 --- /dev/null +++ b/src/views/onboarding-3.php @@ -0,0 +1,194 @@ +settings->is_api_key_pending(); + +/* Stores the account's email and plan, which the connect request did not. */ +if ( ! $tiny_key_pending ) { + $this->settings->get_compressor()->get_status(); +} + +$tiny_stats = Tiny_Bulk_Optimization::get_optimization_statistics( $this->settings ); +$tiny_uncompressed = count( $tiny_stats['available-for-optimization'] ); +?> + +
+ + + + render_steps( 3, $tiny_confirm_email ); ?> + +
+

+

+ +

+
+ +
+
+
+

+ settings->get_email_address() ) + ); + ?> +

+ settings->is_on_free_plan() ) { ?> +

+ +
+ +
+

+ settings->get_email_address() ) + ); + ?> +

+
+ +
+ +
+
+

+
+ +
+ +
+
+

+
+ + +
> +

+ + + + + +

+ + + + +

+ +

+
+
+ +
+
+

+ 0 ) { + printf( + esc_html( + /* translators: %s: number of images */ + _n( + 'Your library has %s image that isn’t compressed yet.', + 'Your library has %s images that aren’t compressed yet.', + $tiny_uncompressed, + 'tiny-compress-images' + ) + ), + esc_html( number_format_i18n( $tiny_uncompressed ) ) + ); + } else { + esc_html_e( 'Your library has no images that need compressing.', 'tiny-compress-images' ); + } + ?> +

+

+
+

+ + +

+
+
+
diff --git a/src/views/onboarding-steps.php b/src/views/onboarding-steps.php new file mode 100644 index 00000000..2a33a1c6 --- /dev/null +++ b/src/views/onboarding-steps.php @@ -0,0 +1,44 @@ + __( 'Account', 'tiny-compress-images' ), + 2 => __( 'Confirm email', 'tiny-compress-images' ), + 3 => __( 'Settings', 'tiny-compress-images' ), +); +if ( ! $confirm_email ) { + unset( $tiny_steps[2] ); +} +$tiny_number = 0; +?> +
    + $tiny_label ) { ?> + + +
  1. + + + + +
  2. + +
  3. + + +
  4. + +
  5. + + +
  6. + + +
diff --git a/test/unit/TinySettingsAdminTest.php b/test/unit/TinySettingsAdminTest.php index f481f5c4..469d2559 100644 --- a/test/unit/TinySettingsAdminTest.php +++ b/test/unit/TinySettingsAdminTest.php @@ -549,7 +549,5 @@ function apache_get_modules() { $this->subject->render_delivery_method(); $output = ob_get_clean(); $this->assertStringContainsString( 'Conversion delivery', $output ); - - } } From 9efedd523589bec9dc37130b934fa0681b6638c3 Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 12 Oct 2026 16:51:06 +0200 Subject: [PATCH 10/45] phpcs --- src/class-tiny-onboarding.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index b70f7f54..c08120aa 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -50,7 +50,7 @@ public function __construct( $settings ) { $this->settings = $settings; } - function admin_init() { + public function admin_init() { add_action( 'admin_post_tiny_onboarding_save_settings', $this->get_method( 'save_settings' ) @@ -111,7 +111,7 @@ function () use ( $step ) { add_action( 'load-' . $hook, $this->get_method( 'set_page_title' ) ); } - function admin_menu() { + public function admin_menu() { $this->page_title = __( 'Welcome to TinyPNG', 'tiny-compress-images' ); $this->add_sub_menu( 1 ); @@ -134,7 +134,7 @@ public function set_page_title() { * * @return boolean true if onboarded */ - function is_onboarded() { + private function is_onboarded() { $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); return 1 === (int) get_option( $onboarding_status_field, 1 ); } @@ -177,7 +177,7 @@ public function get_step_url( $step ) { * * @param int $is_onboarded status */ - static function set_is_onboarded( $is_onboarded ) { + public static function set_is_onboarded( $is_onboarded ) { $onboarding_status_field = self::get_prefixed_name( 'onboarding_status' ); return update_option( $onboarding_status_field, $is_onboarded ); } @@ -252,7 +252,7 @@ public function save_settings() { * A site that already has a key, or that has compressed before, keeps the * default onboarded state so it is never sent through onboarding again. */ - static function on_activate() { + public static function on_activate() { $api_key = get_option( self::get_prefixed_name( 'api_key' ) ); $compression_count = get_option( self::get_prefixed_name( 'status' ) ); From 937d12994318d90af2a9089e83466364ee4225c7 Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 12 Oct 2026 20:32:05 +0200 Subject: [PATCH 11/45] use css variables --- src/css/admin.css | 117 +++++++++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 49 deletions(-) diff --git a/src/css/admin.css b/src/css/admin.css index cc0990f7..217f05ea 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -1,3 +1,22 @@ +body { + --tiny-theme-default: var(--wp-admin-theme-color, #2271b1); + --tiny-text-primary: #12141d; + --tiny-text-secondary: #5b5b67; + --tiny-text-inverse: #fff; + --tiny-surface-primary: #fff; + --tiny-surface-subtle: #ededf1; + --tiny-border-default: #ededf1; + --tiny-border-emphasis: #d4d4db; + --tiny-neutral-300: #b8b8c1; + --tiny-neutral-500: #7a7a86; + --tiny-neutral-700: #4a4b56; + --tiny-neutral-900: #1e2029; + --tiny-red-300: #fca5a5; + --tiny-red-500: #ef4444; + --tiny-shadow-xs: 0 1px 2px rgba(18, 20, 29, 0.05); + --tiny-shadow-sm: 0 1px 2px rgba(18, 20, 29, 0.06); +} + div.misc-pub-section.tiny-compress-images { border-top: 1px solid #e1e1e1; padding-bottom: 16px; @@ -560,8 +579,8 @@ fieldset.tinypng_convert_fields[disabled] { box-sizing: border-box; display: flex; width: 100%; - background: #fff; - border: 1px solid #ededf1; + background: var(--tiny-surface-primary); + border: 1px solid var(--tiny-border-default); border-radius: 12px; } @@ -575,7 +594,7 @@ fieldset.tinypng_convert_fields[disabled] { } .tiny-account-connect-column + .tiny-account-connect-column { - border-left: 1px solid #ededf1; + border-left: 1px solid var(--tiny-border-default); } @media screen and (max-width: 600px) { @@ -584,7 +603,7 @@ fieldset.tinypng_convert_fields[disabled] { } .tiny-account-connect-column + .tiny-account-connect-column { - border-top: 1px solid #ededf1; + border-top: 1px solid var(--tiny-border-default); border-left: 0; } } @@ -592,7 +611,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-card-head h2 { margin: 0; padding: 0; - color: #12141d; + color: var(--tiny-text-primary); font-size: 16px; font-weight: 600; line-height: 24px; @@ -600,7 +619,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-card-head p { margin: 4px 0 0; - color: #5b5b67; + color: var(--tiny-text-secondary); font-size: 14px; line-height: 20px; } @@ -612,7 +631,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-field label { display: block; margin-bottom: 6px; - color: #4a4b56; + color: var(--tiny-neutral-700); font-size: 14px; font-weight: 500; line-height: 20px; @@ -624,40 +643,40 @@ fieldset.tinypng_convert_fields[disabled] { height: auto; margin: 0; padding: 9px 14px; - color: #1e2029; + color: var(--tiny-neutral-900); font-size: 16px; line-height: 24px; - background-color: #fff; - border: 1px solid #b8b8c1; + background-color: var(--tiny-surface-primary); + border: 1px solid var(--tiny-neutral-300); border-radius: 2px; - box-shadow: 0 1px 2px rgba(18, 20, 29, 0.05); + box-shadow: var(--tiny-shadow-xs); } .tiny-field input[type="text"]::placeholder { - color: #7a7a86; + color: var(--tiny-neutral-500); } .tiny-field input[type="text"]:focus { - border-color: var(--wp-admin-theme-color, #2271b1); - box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #2271b1); + border-color: var(--tiny-theme-default); + box-shadow: 0 0 0 1px var(--tiny-theme-default); outline: 2px solid transparent; } .tiny-field-invalid input[type="text"] { padding-right: 38px; - background: #fff url("../images/alert-circle.svg") no-repeat right 14px center; - border-color: #fca5a5; + background: var(--tiny-surface-primary) url("../images/alert-circle.svg") no-repeat right 14px center; + border-color: var(--tiny-red-300); } .tiny-field-invalid input[type="text"]:focus { - border-color: #fca5a5; - box-shadow: 0 0 0 1px #fca5a5; + border-color: var(--tiny-red-300); + box-shadow: 0 0 0 1px var(--tiny-red-300); } .tiny-field-error { display: none; margin: 6px 0 0; - color: #ef4444; + color: var(--tiny-red-500); font-size: 14px; line-height: 20px; } @@ -689,7 +708,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-actions a, .tiny-actions .button-link { - color: var(--wp-admin-theme-color, #2271b1); + color: var(--tiny-theme-default); font-size: 14px; font-weight: 600; line-height: 20px; @@ -729,7 +748,7 @@ fieldset.tinypng_convert_fields[disabled] { align-items: center; gap: 8px; margin: 0; - color: #5b5b67; + color: var(--tiny-text-secondary); font-size: 14px; line-height: 20px; } @@ -740,12 +759,12 @@ fieldset.tinypng_convert_fields[disabled] { width: 40px; height: 2px; margin-right: 4px; - background: #d4d4db; + background: var(--tiny-border-emphasis); border-radius: 1px; } .tiny-onboarding-steps li.tiny-step-done + li:before { - background: var(--wp-admin-theme-color, #2271b1); + background: var(--tiny-theme-default); } @media screen and (max-width: 600px) { @@ -761,29 +780,29 @@ fieldset.tinypng_convert_fields[disabled] { align-items: center; width: 24px; height: 24px; - color: #5b5b67; + color: var(--tiny-text-secondary); font-size: 12px; font-weight: 600; line-height: 16px; letter-spacing: 0.12px; - background: #fff; - border: 1px solid #ededf1; + background: var(--tiny-surface-primary); + border: 1px solid var(--tiny-border-default); border-radius: 12px; } .tiny-step-current { - color: #12141d; + color: var(--tiny-text-primary); font-weight: 600; } .tiny-step-current .tiny-step-dot { - color: #fff; - background: var(--wp-admin-theme-color, #2271b1); + color: var(--tiny-text-inverse); + background: var(--tiny-theme-default); border: 0; } .tiny-step-done .tiny-step-dot { - color: var(--wp-admin-theme-color, #2271b1); + color: var(--tiny-theme-default); background: none; border: 0; } @@ -801,7 +820,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-onboarding-header h1 { margin: 0 0 8px; padding: 0; - color: #12141d; + color: var(--tiny-text-primary); font-size: 24px; font-weight: 600; line-height: 32px; @@ -809,7 +828,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-onboarding-header p { margin: 0; - color: #5b5b67; + color: var(--tiny-text-secondary); font-size: 14px; line-height: 20px; } @@ -817,8 +836,8 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-onboarding-card { box-sizing: border-box; width: 100%; - background: #fff; - border: 1px solid #ededf1; + background: var(--tiny-surface-primary); + border: 1px solid var(--tiny-border-default); border-radius: 12px; } @@ -830,7 +849,7 @@ fieldset.tinypng_convert_fields[disabled] { } .tiny-card-section + .tiny-card-section { - border-top: 1px solid #ededf1; + border-top: 1px solid var(--tiny-border-default); } .tiny-card-section .notice { @@ -849,13 +868,13 @@ fieldset.tinypng_convert_fields[disabled] { display: flex; flex-direction: column; gap: 2px; - color: #5b5b67; + color: var(--tiny-text-secondary); font-size: 16px; line-height: 24px; } .tiny-option-label { - color: #4a4b56; + color: var(--tiny-neutral-700); font-weight: 500; } @@ -868,7 +887,7 @@ fieldset.tinypng_convert_fields[disabled] { min-width: 44px; height: 24px; margin: 0; - background: #ededf1; + background: var(--tiny-surface-subtle); border: 0; border-radius: 12px; box-shadow: none; @@ -885,14 +904,14 @@ fieldset.tinypng_convert_fields[disabled] { width: 20px; height: 20px; margin: 0; - background: #fff; + background: var(--tiny-surface-primary); border-radius: 50%; - box-shadow: 0 1px 2px rgba(18, 20, 29, 0.06); + box-shadow: var(--tiny-shadow-sm); transition: transform 0.15s; } .tiny-option input[type="checkbox"]:checked { - background: var(--wp-admin-theme-color, #2271b1); + background: var(--tiny-theme-default); } .tiny-option input[type="checkbox"]:checked:before { @@ -907,15 +926,15 @@ fieldset.tinypng_convert_fields[disabled] { min-width: 20px; height: 20px; margin: 2px 0 0; - background: #fff; - border: 1px solid #b8b8c1; + background: var(--tiny-surface-primary); + border: 1px solid var(--tiny-neutral-300); border-radius: 50%; box-shadow: none; } .tiny-option input[type="radio"]:checked { - background: var(--wp-admin-theme-color, #2271b1); - border-color: var(--wp-admin-theme-color, #2271b1); + background: var(--tiny-theme-default); + border-color: var(--tiny-theme-default); } .tiny-option input[type="radio"]:checked:before { @@ -926,13 +945,13 @@ fieldset.tinypng_convert_fields[disabled] { height: 8px; margin: 5px; text-indent: 0; - background: #fff; + background: var(--tiny-text-inverse); border-radius: 50%; } .tiny-option input[type="checkbox"]:focus, .tiny-option input[type="radio"]:focus { - box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--wp-admin-theme-color, #2271b1); + box-shadow: 0 0 0 2px var(--tiny-surface-primary), 0 0 0 4px var(--tiny-theme-default); outline: 2px solid transparent; } @@ -949,7 +968,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-option-group { margin: 0; - color: #12141d; + color: var(--tiny-text-primary); font-size: 14px; font-weight: 600; line-height: 20px; @@ -957,7 +976,7 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-format-note { margin: 0; - color: #5b5b67; + color: var(--tiny-text-secondary); font-size: 14px; line-height: 20px; } \ No newline at end of file From 599a051807c262386d46500910939af2aca4e2f1 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 13 Oct 2026 09:03:01 +0200 Subject: [PATCH 12/45] do not redirect on admin_init --- src/class-tiny-onboarding.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index c08120aa..d03d8396 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -65,9 +65,6 @@ public function admin_init() { if ( self::is_bulk_activation() ) { return; } - - wp_safe_redirect( $this->get_step_url( 1 ) ); - exit(); } /** From 9f6d6576698d706cdd1e7beccf97881b9fd5cf97 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 13 Oct 2026 09:39:59 +0200 Subject: [PATCH 13/45] reuse rendering for modern formats --- src/class-tiny-settings.php | 37 +++++----- src/css/admin.css | 4 +- src/views/onboarding-3.php | 98 ++------------------------- src/views/onboarding-steps.php | 4 -- src/views/settings-modern-formats.php | 70 +++++++++++++++++++ 5 files changed, 94 insertions(+), 119 deletions(-) create mode 100644 src/views/settings-modern-formats.php diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 1ee516cf..854e4c44 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -228,7 +228,7 @@ public function has_api_key() { return (bool) $this->get_api_key(); } - protected function get_api_key_pending() { + public function get_api_key_pending() { if ( defined( 'TINY_API_KEY' ) ) { return false; } else { @@ -240,25 +240,6 @@ protected function clear_api_key_pending() { delete_option( self::get_prefixed_name( 'api_key_pending' ) ); } - /** - * Whether the API key of a new account still waits for its email to be - * confirmed. While pending, asks the API and clears it once the key works. - * - * @return bool - */ - public function is_api_key_pending() { - if ( ! $this->get_api_key_pending() ) { - return false; - } - - if ( $this->compressor->get_status()->ok ) { - $this->clear_api_key_pending(); - return false; - } - - return true; - } - /** * Whether the API key still waits for its activation link to be opened. * Asks the API only while the key is pending, and clears the flag once @@ -915,6 +896,22 @@ public function render_account_connect() { include __DIR__ . '/views/account-status-connect.php'; } + /** + * Renders the switch for WebP or AVIF versions, with the format and the delivery to use. + * Server rules are recommended, so they are selected when Apache can rewrite and + * no delivery was chosen yet. + */ + public function render_modern_formats() { + $convert = $this->get_conversion_enabled(); + $convert_to = self::get_convert_format_option( 'convert_to', 'smallest' ); + $server_rules = self::can_render_delivery_method(); + $delivery = self::get_convert_format_option( + 'delivery_method', + $server_rules ? 'htaccess' : 'picture' + ); + include __DIR__ . '/views/settings-modern-formats.php'; + } + public function render_pending_status() { $key = $this->get_api_key(); if ( empty( $key ) ) { diff --git a/src/css/admin.css b/src/css/admin.css index 217f05ea..dd852f72 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -867,7 +867,6 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-option-text { display: flex; flex-direction: column; - gap: 2px; color: var(--tiny-text-secondary); font-size: 16px; line-height: 24px; @@ -878,7 +877,6 @@ fieldset.tinypng_convert_fields[disabled] { font-weight: 500; } -/* Toggle switch, drawn over WordPress' own checkbox styles. */ .tiny-option input[type="checkbox"] { box-sizing: border-box; position: relative; @@ -886,7 +884,7 @@ fieldset.tinypng_convert_fields[disabled] { width: 44px; min-width: 44px; height: 24px; - margin: 0; + margin: 4px 0; background: var(--tiny-surface-subtle); border: 0; border-radius: 12px; diff --git a/src/views/onboarding-3.php b/src/views/onboarding-3.php index cec0fa49..838d14c3 100644 --- a/src/views/onboarding-3.php +++ b/src/views/onboarding-3.php @@ -1,33 +1,6 @@ settings->is_api_key_pending(); - -/* Stores the account's email and plan, which the connect request did not. */ -if ( ! $tiny_key_pending ) { - $this->settings->get_compressor()->get_status(); -} - +$tiny_key_pending = $this->settings->get_api_key_pending(); +$tiny_auto_compress = $this->settings->get_compression_timing() === 'auto'; $tiny_stats = Tiny_Bulk_Optimization::get_optimization_statistics( $this->settings ); $tiny_uncompressed = count( $tiny_stats['available-for-optimization'] ); ?> @@ -36,7 +9,7 @@ - render_steps( 3, $tiny_confirm_email ); ?> + render_steps( 3 ); ?>
@@ -91,71 +64,12 @@ > - +
-
-
-

-
- - -
> -

- - - - - -

- - - - -

- -

-
-
+ settings->render_modern_formats(); ?>
@@ -183,7 +97,7 @@

'; } - public function render_compression_timing_settings() { - $heading = esc_html__( - 'When should new images be compressed?', - 'tiny-compress-images' - ); - echo '

' . esc_html( $heading ) . '

'; - echo '
'; - - $name = self::get_prefixed_name( 'compression_timing' ); - $compression_timing = $this->get_compression_timing(); - - $id = self::get_prefixed_name( 'background_compress_enabled' ); - $checked = ( 'background' === $compression_timing ); - - $label = esc_html__( - 'Compress new images in the background (Recommended)', - 'tiny-compress-images' - ); - $description = esc_html__( - 'This is the fastest method, but can cause issues with some image related plugins.', - 'tiny-compress-images' - ); - - $this->render_compression_timing_radiobutton( - $name, - $label, - $description, - 'background', - $checked - ); - - $id = self::get_prefixed_name( 'auto_compress_enabled' ); - $checked = ( 'auto' === $compression_timing ); - - $label = esc_html__( - 'Compress new images during upload', - 'tiny-compress-images' - ); - $description = esc_html__( - 'Uploads will take longer, but provides higher compatibility with other plugins.', - 'tiny-compress-images' - ); - - $this->render_compression_timing_radiobutton( - $name, - $label, - $description, - 'auto', - $checked - ); - - $id = self::get_prefixed_name( 'auto_compress_disabled' ); - $checked = ( 'manual' === $compression_timing ); - - $label = esc_html__( - 'Do not compress new images automatically', - 'tiny-compress-images' - ); - $description = esc_html__( - 'Manually select the images you want to compress in the media library.', - 'tiny-compress-images' - ); - - $this->render_compression_timing_radiobutton( - $name, - $label, - $description, - 'manual', - $checked - ); - - echo '
'; - } - public function render_sizes() { $dummy_size_name = self::get_prefixed_name( 'sizes[' . self::DUMMY_SIZE . ']' ); echo ''; - - $this->render_size_checkboxes_description( - count( self::get_active_tinify_sizes() ), - self::get_resize_enabled(), - self::compress_wr2x_images(), - self::get_conversion_enabled() - ); - - echo '
'; } private function render_size_checkboxes( $size, $option ) { $id = self::get_prefixed_name( "sizes_$size" ); $name = self::get_prefixed_name( 'sizes[' . $size . ']' ); $checked = ! empty( $option['tinify'] ); + echo ''; + } + + /** + * Names a size like "Thumbnail (150 × 150)", "Medium large (768 wide)" or, + * when the name is just its dimensions, "1536 × 1536". + */ + public static function get_size_label( $size, $option ) { if ( Tiny_Image::is_original( $size ) ) { - $label = esc_html__( 'Original image', 'tiny-compress-images' ) . ' (' . - esc_html__( - 'overwritten by compressed image', - 'tiny-compress-images' - ) . ')'; - } elseif ( Tiny_Image::is_retina( $size ) ) { - $label = esc_html__( 'WP Retina 2x sizes', 'tiny-compress-images' ); - } else { - $width = $option['width']; - if ( ! $width ) { - $width = '?'; - } + return __( + 'Original image (replaced by the compressed version)', + 'tiny-compress-images' + ); + } + if ( Tiny_Image::is_retina( $size ) ) { + return __( 'WP Retina 2x sizes', 'tiny-compress-images' ); + } - $height = $option['height']; - if ( ! $height ) { - $height = '?'; - } + $width = intval( $option['width'] ); + $height = intval( $option['height'] ); + if ( ! $height ) { + /* translators: %s: width in pixels */ + $dimensions = sprintf( __( '%s wide', 'tiny-compress-images' ), $width ); + } elseif ( ! $width ) { + /* translators: %s: height in pixels */ + $dimensions = sprintf( __( '%s tall', 'tiny-compress-images' ), $height ); + } else { + $dimensions = $width . ' × ' . $height; + } - $label = esc_html( ucfirst( str_replace( '_', ' ', $size ) ) ) - . ' - ' . $width . 'x' . $height; + if ( $size === $width . 'x' . $height ) { + return $dimensions; } - echo '

'; - echo ''; - echo ''; - echo '

'; + return ucfirst( str_replace( '_', ' ', $size ) ) . ' (' . $dimensions . ')'; } - public function render_size_checkboxes_description( + /** + * Credits one new upload uses: one per size, one more when the original is + * resized, doubled for retina sizes and doubled again for WebP or AVIF versions. + */ + public static function get_credits_per_image( $active_sizes_count, $resize_original_enabled, $compress_wr2x, $conversion_enabled ) { - echo '

'; - esc_html_e( - 'Remember each selected size counts as a compression.', - 'tiny-compress-images' - ); - echo '

'; - echo '

'; + $credits = $active_sizes_count; if ( $resize_original_enabled ) { - ++$active_sizes_count; + ++$credits; } if ( $compress_wr2x ) { - $active_sizes_count *= 2; + $credits *= 2; } - if ( $conversion_enabled ) { - $active_sizes_count *= 2; + $credits *= 2; } + return $credits; + } + public function render_size_checkboxes_description( + $active_sizes_count, + $resize_original_enabled, + $compress_wr2x, + $conversion_enabled + ) { + echo '

'; if ( $active_sizes_count < 1 ) { esc_html_e( 'With these settings no images will be compressed.', 'tiny-compress-images' ); } else { - $free_images_per_month = floor( - Tiny_Config::MONTHLY_FREE_COMPRESSIONS / $active_sizes_count + $credits = self::get_credits_per_image( + $active_sizes_count, + $resize_original_enabled, + $compress_wr2x, + $conversion_enabled ); - - $strong = array( - 'strong' => array(), + printf( + esc_html( + /* translators: %s: number of image sizes */ + _n( + '%s size selected.', + '%s sizes selected.', + $active_sizes_count, + 'tiny-compress-images' + ) + ), + esc_html( number_format_i18n( $active_sizes_count ) ) ); - + echo ' '; printf( - wp_kses( - /* translators: %1$s: number of images */ - __( - // phpcs:ignore Generic.Files.LineLength - 'With these settings you can compress at least %1$s images for free each month.', + esc_html( + /* translators: %s: number of credits */ + _n( + 'Each new image uses %s credit.', + 'Each new image uses %s credits.', + $credits, 'tiny-compress-images' - ), - $strong + ) ), - esc_html( $free_images_per_month ) + esc_html( number_format_i18n( $credits ) ) ); - - if ( self::wr2x_active() ) { - echo '

'; - echo '

'; - esc_html_e( - 'If selected, retina sizes will be compressed when generated by WP Retina 2x', - 'tiny-compress-images' - ); - echo '
'; - esc_html_e( - 'Each retina size will count as an additional compression.', - 'tiny-compress-images' - ); - } - } // End if(). + } echo '

'; - } - public function render_compression_timing_radiobutton( - $name, - $label, - $desc, - $value, - $checked - ) { - $as3cf_local_files_present = Tiny_AS3CF::is_active() - && Tiny_AS3CF::remove_local_files_setting_enabled(); - if ( 'background' == $value && $as3cf_local_files_present && $checked ) { - echo '

'; - echo '' . esc_html__( 'Warning', 'tiny-compress-images' ) . ' — '; - $message = esc_html__( - // phpcs:ignore Generic.Files.LineLength - 'For compression to work you will need to configure WP Offload S3 to keep a copy of the images on the server.', + if ( self::wr2x_active() ) { + echo '

'; + esc_html_e( + 'If selected, retina sizes will be compressed when generated by WP Retina 2x', 'tiny-compress-images' ); - echo esc_html( $message ); - echo '

'; - echo '

'; - } else { - echo '

'; + echo '
'; + esc_html_e( + 'Each retina size will count as an additional compression.', + 'tiny-compress-images' + ); + echo '

'; } + } - $id = sprintf( self::get_prefixed_name( 'compression_timing_%s' ), $value ); - $label = esc_html( $label ); - $desc = esc_html( $desc ); - echo ''; - echo ''; - echo '
'; - echo '' . esc_html( $desc ) . ''; + public function render_credits_per_image( $credits ) { + echo '

'; + printf( + esc_html( + /* translators: %s: number of credits */ + _n( + 'With your settings, each new image uses %s credit.', + 'With your settings, each new image uses %s credits.', + $credits, + 'tiny-compress-images' + ) + ), + esc_html( number_format_i18n( $credits ) ) + ); echo '

'; } @@ -860,22 +800,11 @@ public function after_compress_callback( $compressor ) { public function render_account_status() { $key = $this->get_api_key(); - if ( empty( $key ) ) { - $compressor = $this->get_compressor(); - if ( $compressor->can_create_key() ) { - include __DIR__ . '/views/account-status-create-advanced.php'; - } else { - include __DIR__ . '/views/account-status-create-simple.php'; - } - } else { + if ( ! empty( $key ) ) { if ( $this->refresh_api_key_pending() ) { $status = (object) array( 'ok' => true, 'pending' => true, - 'message' => __( - 'An email has been sent to activate your account', - 'tiny-compress-images' - ), ); } else { $status = $this->compressor->get_status(); @@ -902,6 +831,7 @@ public function render_account_connect() { * no delivery was chosen yet. */ public function render_modern_formats() { + $convert_name = self::get_prefixed_name( 'convert_format' ); $convert = $this->get_conversion_enabled(); $convert_to = self::get_convert_format_option( 'convert_to', 'smallest' ); $server_rules = self::can_render_delivery_method(); @@ -914,14 +844,7 @@ public function render_modern_formats() { public function render_pending_status() { $key = $this->get_api_key(); - if ( empty( $key ) ) { - $compressor = $this->get_compressor(); - if ( $compressor->can_create_key() ) { - include __DIR__ . '/views/account-status-create-advanced.php'; - } else { - include __DIR__ . '/views/account-status-create-simple.php'; - } - } else { + if ( ! empty( $key ) ) { include __DIR__ . '/views/account-status-loading.php'; } } @@ -1044,36 +967,17 @@ public function get_wr2x_option() { ); } + /** + * Wether or not wr2x is active + * + * @return bool true if active + */ public function compress_wr2x_images() { $option = $this->get_wr2x_option(); return self::wr2x_active() && $option['tinify']; } - public function render_format_conversion() { - include __DIR__ . '/views/settings-conversion.php'; - } - - private static function render_radiobutton( - $group_name, - $option_id, - $option_value, - $current_value, - $label, - $descr - ) { - $checked = ( $current_value === $option_value ); - echo '

'; - echo ''; - echo ''; - echo '

'; - } - - /** * @return bool true if apache with mod_rewrite loaded */ @@ -1094,17 +998,6 @@ public static function can_render_delivery_method() { return in_array( 'mod_rewrite', $modules, true ); } - /** - * If possible, render the delivery method settings view. - */ - public function render_delivery_method() { - if ( ! self::can_render_delivery_method() ) { - return; - } - - include __DIR__ . '/views/settings-conversion-delivery.php'; - } - private static function get_convert_format_option( $option, $default_value ) { $setting = get_option( self::get_prefixed_name( 'convert_format' ) ); if ( isset( $setting[ $option ] ) && $setting[ $option ] ) { diff --git a/src/css/admin.css b/src/css/admin.css index dd852f72..5a3bff0b 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -5,6 +5,7 @@ body { --tiny-text-inverse: #fff; --tiny-surface-primary: #fff; --tiny-surface-subtle: #ededf1; + --tiny-surface-page: #f8f8fa; --tiny-border-default: #ededf1; --tiny-border-emphasis: #d4d4db; --tiny-neutral-300: #b8b8c1; @@ -13,6 +14,7 @@ body { --tiny-neutral-900: #1e2029; --tiny-red-300: #fca5a5; --tiny-red-500: #ef4444; + --tiny-green-500: #06d28b; --tiny-shadow-xs: 0 1px 2px rgba(18, 20, 29, 0.05); --tiny-shadow-sm: 0 1px 2px rgba(18, 20, 29, 0.06); } @@ -23,238 +25,6 @@ div.misc-pub-section.tiny-compress-images { } /* Settings */ -div.tiny-account-status { - box-sizing: border-box; - display: table-cell; - width: 570px; - border: 1px solid #e1e1e1; - background-color: white; -} - -@media only screen and (max-width: 800px) { - div.tiny-account-status { - max-width: 100%; - } -} - -div.tiny-account-status div.status { - box-sizing: border-box; - padding: 22px 28px 14px 28px; - position: relative; -} - -div.tiny-account-status h4 { - margin-top: 0; - margin-bottom: 9px; - line-height: 1.5; -} - -div.tiny-account-status p { - margin-top: 0; - margin-bottom: 8px; -} - -div.tiny-account-status button+p { - margin-top: 8px; -} - -div.tiny-account-status p:first-child { - margin-bottom: 10px; -} - -div.tiny-account-status p:last-child { - margin-bottom: 0; -} - -div.tiny-account-status p.introduction { - margin-bottom: 12px; -} - -div.tiny-account-status p.status { - font-weight: bold; -} - -div.tiny-account-status p.status a { - text-decoration: none; - font-weight: normal; -} - -div.tiny-account-status p.status span { - position: relative; - margin-right: 30px; -} - -div.tiny-account-status p.status span:after { - box-sizing: border-box; - position: absolute; - width: 20px; - height: 20px; - font-size: 28px; - font-family: dashicons; - font-weight: 400; - font-style: normal; -} - -div.tiny-account-status div.status-loading { - padding-left: 56px; -} - -div.tiny-account-status div.status-loading:before { - position: absolute; - left: 26px; - top: 22px; - height: 20px; - width: 20px; - background: url("../images/spinner.gif") no-repeat center; - background-size: 20px 20px; - content: ""; -} - -div.tiny-account-status div.status-loading p { - margin-bottom: 10px; -} - -@media (-webkit-min-device-pixel-ratio: 1.25), -(min-resolution: 120dpi) { - div.tiny-account-status div.status-loading:before { - background-image: url("../images/spinner-2x.gif"); - } -} - -div.tiny-account-status div.status-success p { - display: inline-block; - position: relative; - margin-bottom: 10px; -} - -div.tiny-account-status div.status-success p.status span:after { - right: -18px; - top: -11px; - color: #30d030; - content: "\f147"; -} - -div.tiny-account-status div.status-failure p.status span:after { - right: -20px; - top: -11px; - color: #d54e21; - content: "\f158"; -} - -div.tiny-account-status div.status-pending p.status span:after { - right: -26px; - top: -9px; - color: #0086ba; - font-size: 24px; - content: "\f466"; -} - -div.tiny-account-status div.status-pending p.status span { - margin-right: 38px; -} - -div.tiny-account-status div.failure input { - border-color: #dc3232; - background-color: #fdf1f1; -} - -div.tiny-account-status div.failure p.message { - color: #dc3232; -} - -div.tiny-account-status div.update { - padding: 22px 28px; -} - -div.tiny-account-status div.upgrade { - padding: 22px 28px; - background-color: #f5f9fa; -} - -div.tiny-account-status div.upgrade p { - margin-bottom: 0; -} - -div.tiny-account-status div.upgrade div.button-container { - float: right; - margin-left: 20px; - margin-bottom: 5px; -} - -@media only screen and (max-width: 400px) { - div.tiny-account-status div.upgrade div.button-container { - float: none; - margin-left: 0; - margin-bottom: 10px; - } -} - -div.tiny-account-status.wide { - width: auto; - max-width: 700px; -} - -div.tiny-account-status.wide div.create { - box-sizing: border-box; - display: table-cell; - width: 50%; - padding: 22px 28px; - border-right: 1px solid #e5e5e5; -} - -div.tiny-account-status.wide div.update { - box-sizing: border-box; - display: table-cell; - width: 50%; - padding-left: 29px; -} - -@media screen and (max-width: 600px) { - div.tiny-account-status.wide div.create { - display: block; - width: auto; - padding-bottom: 20px; - border-right: 0; - border-bottom: 1px solid #e5e5e5; - } - - div.tiny-account-status.wide div.update { - display: block; - width: auto; - padding-top: 29px; - } -} - -div.tiny-account-status.wide div.create input, -div.tiny-account-status.wide div.update input { - width: 100%; - margin-bottom: 8px; -} - -div.tiny-account-status.wide div.create button, -div.tiny-account-status.wide div.update button { - width: 100%; - margin-top: 4px; - margin-bottom: 6px; -} - -div.tiny-account-status.wide div.create button.loading, -div.tiny-account-status.wide div.update button.loading { - background-image: url(../images/button-spinner.gif) !important; - background-size: 16px 16px !important; - background-position: 95% 50% !important; - background-repeat: no-repeat !important; -} - -@media (-webkit-min-device-pixel-ratio: 1.25), -(min-resolution: 120dpi) { - - div.tiny-account-status.wide div.create button.loading, - div.tiny-account-status.wide div.update button.loading { - background-image: url("../images/button-spinner-2x.gif") !important; - } -} - .incompatible-plugins.error h3 { margin: 10px 0; } @@ -271,81 +41,6 @@ div.tiny-account-status.wide div.update button.loading { padding-right: 100px; } -table.tinify-settings p.intro { - color: #777777; -} - -table.tinify-settings div.sizes, -table.tinify-settings div.conversion-options { - margin: 20px 0 5px; -} - -table.tinify-settings input[type="checkbox"], -table.tinify-settings input[type="radio"] { - margin-right: 12px; -} - -div.optimization-options { - margin-top: 20px; -} - -p.tiny-radio { - margin-right: 12px; -} - -p.tiny-radio span.description, -p.tiny-check span.description { - display: block; -} - -@media only screen and (max-width: 782px) { - p.tiny-radio span.description { - margin-left: 38px; - } -} - -p.tiny-radio.disabled label, -p.tiny-radio.disabled span.description { - opacity: 0.5; -} - -p.tiny-radio span, -p.tiny-check span.description { - margin-left: 28px; - color: #777777; - line-height: 30px; -} - -div.tiny-resize-available { - margin-top: 5px; -} - -div.tiny-resize-resolution { - margin: 10px 0 20px 28px; -} - -@media only screen and (max-width: 782px) { - div.tiny-resize-resolution { - margin-left: 38px; - } -} - -div.tiny-resize-resolution span { - color: #777777; -} - -div.tiny-resize-inputs { - margin-top: 10px; -} - -div.tiny-resize-inputs input { - margin-right: 6px; -} - -input[type=number][name*="tinypng_resize_original"] { - width: 65px; -} - /* Media library */ .tiny-compress-images .spinner.hidden { display: none; @@ -467,37 +162,6 @@ input[type=number][name*="tinypng_resize_original"] { margin: 5px 0; } -fieldset.tinypng_convert_fields { - margin: 18px 28px; -} - -fieldset.tinypng_convert_fields p { - display: flex; -} - -fieldset.tinypng_convert_fields p input[type=radio] { - margin-top: 2px; -} - -fieldset.tinypng_convert_fields label span { - display: flex; - margin-left: 0; - margin-top: 2px; - line-height: 1.5; -} - -fieldset.tinypng_convert_fields[disabled] { - opacity: 0.6; -} - -.tiny-d-flex { - display: flex; -} - -.tiny-mt-2 { - margin-top: 10px; -} - .tiny-dialog { padding: 20px; border: 1px solid #ccd0d4; @@ -593,7 +257,7 @@ fieldset.tinypng_convert_fields[disabled] { padding: 24px; } -.tiny-account-connect-column + .tiny-account-connect-column { +.tiny-account-connect-column+.tiny-account-connect-column { border-left: 1px solid var(--tiny-border-default); } @@ -602,7 +266,7 @@ fieldset.tinypng_convert_fields[disabled] { flex-direction: column; } - .tiny-account-connect-column + .tiny-account-connect-column { + .tiny-account-connect-column+.tiny-account-connect-column { border-top: 1px solid var(--tiny-border-default); border-left: 0; } @@ -612,16 +276,14 @@ fieldset.tinypng_convert_fields[disabled] { margin: 0; padding: 0; color: var(--tiny-text-primary); - font-size: 16px; + font-size: 15px; font-weight: 600; - line-height: 24px; + line-height: 20px; } .tiny-card-head p { margin: 4px 0 0; color: var(--tiny-text-secondary); - font-size: 14px; - line-height: 20px; } .tiny-field { @@ -637,14 +299,14 @@ fieldset.tinypng_convert_fields[disabled] { line-height: 20px; } -.tiny-field input[type="text"] { +.tiny-field input[type="text"], +.tiny-field input[type="number"] { box-sizing: border-box; width: 100%; height: auto; margin: 0; padding: 9px 14px; color: var(--tiny-neutral-900); - font-size: 16px; line-height: 24px; background-color: var(--tiny-surface-primary); border: 1px solid var(--tiny-neutral-300); @@ -656,7 +318,8 @@ fieldset.tinypng_convert_fields[disabled] { color: var(--tiny-neutral-500); } -.tiny-field input[type="text"]:focus { +.tiny-field input[type="text"]:focus, +.tiny-field input[type="number"]:focus { border-color: var(--tiny-theme-default); box-shadow: 0 0 0 1px var(--tiny-theme-default); outline: 2px solid transparent; @@ -754,7 +417,7 @@ fieldset.tinypng_convert_fields[disabled] { } /* The connector to the previous step. */ -.tiny-onboarding-steps li + li:before { +.tiny-onboarding-steps li+li:before { content: ""; width: 40px; height: 2px; @@ -763,12 +426,12 @@ fieldset.tinypng_convert_fields[disabled] { border-radius: 1px; } -.tiny-onboarding-steps li.tiny-step-done + li:before { +.tiny-onboarding-steps li.tiny-step-done+li:before { background: var(--tiny-theme-default); } @media screen and (max-width: 600px) { - .tiny-onboarding-steps li + li:before { + .tiny-onboarding-steps li+li:before { display: none; } } @@ -833,7 +496,8 @@ fieldset.tinypng_convert_fields[disabled] { line-height: 20px; } -.tiny-onboarding-card { +.tiny-onboarding-card, +.tiny-card { box-sizing: border-box; width: 100%; background: var(--tiny-surface-primary); @@ -848,10 +512,14 @@ fieldset.tinypng_convert_fields[disabled] { padding: 24px; } -.tiny-card-section + .tiny-card-section { +.tiny-card-section+.tiny-card-section { border-top: 1px solid var(--tiny-border-default); } +.tiny-card-section[hidden] { + display: none; +} + .tiny-card-section .notice { margin: 0; } @@ -868,8 +536,6 @@ fieldset.tinypng_convert_fields[disabled] { display: flex; flex-direction: column; color: var(--tiny-text-secondary); - font-size: 16px; - line-height: 24px; } .tiny-option-label { @@ -877,7 +543,7 @@ fieldset.tinypng_convert_fields[disabled] { font-weight: 500; } -.tiny-option input[type="checkbox"] { +.tiny-option input[role="switch"] { box-sizing: border-box; position: relative; flex: none; @@ -892,8 +558,8 @@ fieldset.tinypng_convert_fields[disabled] { transition: background-color 0.15s; } -.tiny-option input[type="checkbox"]:before, -.tiny-option input[type="checkbox"]:checked:before { +.tiny-option input[role="switch"]:before, +.tiny-option input[role="switch"]:checked:before { content: ""; position: absolute; top: 2px; @@ -908,14 +574,48 @@ fieldset.tinypng_convert_fields[disabled] { transition: transform 0.15s; } -.tiny-option input[type="checkbox"]:checked { +.tiny-option input[role="switch"]:checked { background: var(--tiny-theme-default); } -.tiny-option input[type="checkbox"]:checked:before { +.tiny-option input[role="switch"]:checked:before { transform: translateX(20px); } +.tiny-check { + align-items: center; +} + +.tiny-check input[type="checkbox"] { + box-sizing: border-box; + flex: none; + width: 20px; + min-width: 20px; + height: 20px; + margin: 0; + background: var(--tiny-surface-primary); + border: 1px solid var(--tiny-neutral-300); + border-radius: 2px; + box-shadow: none; +} + +.tiny-check input[type="checkbox"]:checked { + background: var(--tiny-theme-default); + border-color: var(--tiny-theme-default); +} + +.tiny-check input[type="checkbox"]:checked:before { + content: ""; + display: block; + float: none; + width: 5px; + height: 10px; + margin: 2px 0 0 6px; + border: solid var(--tiny-text-inverse); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + /* Radio button, drawn over WordPress' own radio styles. */ .tiny-option input[type="radio"] { box-sizing: border-box; @@ -967,14 +667,286 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-option-group { margin: 0; color: var(--tiny-text-primary); + font-weight: 600; +} + +.tiny-format-note { + margin: 0; + color: var(--tiny-text-secondary); +} + +/* Settings page */ +.tiny-settings h1 { + margin: 22px 0 8px; + padding: 0; + color: var(--tiny-text-primary); + font-size: 24px; + font-weight: 600; + line-height: 32px; +} + +.tiny-settings-intro { + margin: 0; + color: var(--tiny-text-secondary); + font-size: 14px; + line-height: 20px; +} + +.tiny-settings .wp-header-end { + visibility: hidden; + margin: -1px 0 0; +} + +.tiny-settings-columns { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 24px; + margin-top: 24px; + max-width: 1200px; +} + +.tiny-settings-main { + display: flex; + flex: 999 1 560px; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +.tiny-settings-sidebar { + display: flex; + flex: 1 1 320px; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +.tiny-sizes { + display: flex; + flex-direction: column; + gap: 10px; +} + +.tiny-credit-summary { + padding: 16px; + color: var(--tiny-text-primary); font-size: 14px; font-weight: 600; line-height: 20px; + background: var(--tiny-surface-page); + border-radius: 8px; } -.tiny-format-note { +.tiny-credit-summary p, +.tiny-card-footer p { + margin: 0; +} + +.tiny-credit-summary p+p { + margin-top: 8px; + font-weight: 400; +} + +.tiny-resize-inputs { + flex-direction: row; + flex-wrap: wrap; +} + +.tiny-resize-inputs input[type="number"] { + width: 120px; +} + +.tiny-account-loading { + display: flex; + align-items: center; + gap: 8px; margin: 0; color: var(--tiny-text-secondary); font-size: 14px; line-height: 20px; +} + +.tiny-account-loading .spinner { + float: none; + margin: 0; +} + +.tiny-account-details, +.tiny-credit-rows { + display: flex; + flex-direction: column; + margin: 0; +} + +.tiny-account-details { + gap: 8px; +} + +.tiny-credit-rows { + gap: 10px; +} + +.tiny-account-details div, +.tiny-credit-rows div { + display: flex; + gap: 10px; +} + +.tiny-account-details div { + justify-content: space-between; +} + +.tiny-account-details dt, +.tiny-credit-rows dd { + color: var(--tiny-text-secondary); +} + +.tiny-account-details dd, +.tiny-credit-rows dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.tiny-account-details dd { + color: var(--tiny-text-primary); + font-weight: 500; + text-align: right; +} + +.tiny-credit-rows dt { + flex: none; + width: 72px; + color: var(--tiny-text-primary); + font-weight: 600; +} + +.tiny-credits { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tiny-credits-figure { + display: flex; + align-items: baseline; + gap: 8px; + margin: 0; + color: var(--tiny-text-secondary); + font-size: 14px; + line-height: 20px; +} + +.tiny-credits-figure strong { + color: var(--tiny-text-primary); + font-size: 24px; + font-weight: 600; + line-height: 32px; +} + +.tiny-credits-empty strong { + color: var(--tiny-red-500); +} + +.tiny-credits-bar { + height: 8px; + overflow: hidden; + background: var(--tiny-border-emphasis); + border-radius: 4px; +} + +.tiny-credits-bar span { + display: block; + height: 100%; + background: var(--tiny-green-500); +} + +.tiny-credits-key { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--tiny-text-secondary); + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.12px; +} + +.tiny-credits-key:before { + content: ""; + width: 10px; + height: 10px; + background: var(--tiny-green-500); + border-radius: 2px; +} + +.tiny-account-links { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 0; + color: var(--tiny-text-secondary); + font-size: 14px; + line-height: 20px; +} + +.tiny-account-links a, +.tiny-credit-option a { + color: var(--tiny-theme-default); + font-weight: 600; + text-decoration: none; +} + +.tiny-account-links .tiny-link-secondary { + color: var(--tiny-text-secondary); + font-size: inherit; + font-weight: 500; + line-height: inherit; + text-decoration: none; +} + +.tiny-card-footer { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px; + color: var(--tiny-text-primary); + font-size: 14px; + font-weight: 600; + line-height: 20px; + background: var(--tiny-surface-page); + border-top: 1px solid var(--tiny-border-default); + border-radius: 0 0 12px 12px; +} + +.tiny-card-footer h3 { + margin: 0; + font-size: 14px; + line-height: 20px; +} + +.tiny-credit-option { + display: flex; + flex-direction: column; + gap: 4px; + font-weight: 400; +} + +.tiny-credit-option strong { + font-weight: 500; +} + +.tiny-credit-option>span { + color: var(--tiny-text-secondary); +} + +.tiny-credit-option a { + margin-top: 4px; +} + +.tiny-credit-option+.tiny-credit-option { + padding-top: 12px; + border-top: 1px solid var(--tiny-border-default); } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index 1e818d8e..00104fe6 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -69,13 +69,11 @@ function downloadDiagnostics() { try { - jQuery('#download-diagnostics-spinner').show(); jQuery('#tiny-download-diagnostics').attr('disabled', true); const downloadURL = `${ajaxurl}?action=tiny_download_diagnostics&security=${tinyCompress.nonce}`; window.location.href = downloadURL; } finally { jQuery('#tiny-download-diagnostics').attr('disabled', false); - jQuery('#download-diagnostics-spinner').hide(); } } jQuery('#tiny-download-diagnostics').click(downloadDiagnostics); @@ -196,16 +194,9 @@ } } - function toggleChangeKey(event) { - jQuery('div.tiny-account-status div.update').toggle(); - jQuery('div.tiny-account-status div.status').toggle(); - jQuery('div.tiny-account-status div.upgrade').toggle(); - return false; - } - /** * Creates an account or connects an API key from the account connect view, - * then opens the onboarding step in the page's data-create-key-next or + * then opens the page in its container's data-create-key-next or * data-update-key-next. A failure is shown under the field it is about. */ async function submitAccountConnect(event) { @@ -266,7 +257,7 @@ } const status = await response.json(); if (status.ok) { - window.location.href = document.querySelector('.tiny-onboarding').dataset[`${action}KeyNext`]; + window.location.href = button.closest('[data-create-key-next]').dataset[`${action}KeyNext`]; } else { showError(status.message); } @@ -292,65 +283,6 @@ } } - function submitKey(event) { - event.preventDefault(); - jQuery(event.target).attr({disabled: true}).addClass('loading'); - - var action; - var parent = jQuery(event.target).closest('div'); - var key; - var email; - var name; - - if (jQuery(event.target).data('tiny-action') === 'update-key') { - action = 'update'; - key = parent.find('#tinypng_api_key').val(); - } else if (jQuery(event.target).data('tiny-action') === 'create-key') { - action = 'create'; - name = parent.find('#tinypng_api_key_name').val(); - email = parent.find('#tinypng_api_key_email').val(); - } else { - return false; - } - - jQuery.ajax({ - url: ajaxurl, - type: 'POST', - data: { - _nonce: tinyCompress.nonce, - action: 'tiny_settings_' + action + '_api_key', - key: key, - name: name, - email: email - }, - success: function(json) { - var status = jQuery.parseJSON(json); - - if (status.ok) { - var target = jQuery('#tiny-account-status'); - if (target.length) { - jQuery.get(ajaxurl + (ajaxurl.indexOf( '?' ) > 0 ? '&' : '?') + 'action=tiny_account_status', function(data) { - jQuery(event.target).attr({disabled: false}).removeClass('loading'); - target.replaceWith(data); - }); - } - jQuery('div.tiny-notice[data-name="setting"]').remove(); - } else { - jQuery(event.target).attr({disabled: false}).removeClass('loading'); - parent.addClass('failure'); - parent.find('p.message').text(status.message).show(); - } - }, - error: function() { - jQuery(event.target).attr({disabled: false}).removeClass('loading'); - parent.addClass('failure'); - parent.find('p.message').text('Something went wrong, try again soon').show(); - } - }); - - return false; - } - function dismissNotice(event) { var element = jQuery(event.target); var notice = element.closest('.tiny-notice'); @@ -376,35 +308,80 @@ return false; } - function updateResizeSettings() { - if (propOf('#tinypng_sizes_0', 'checked')) { - jQuery('.tiny-resize-available').show(); - jQuery('.tiny-resize-unavailable').hide(); - } else { - jQuery('.tiny-resize-available').hide(); - jQuery('.tiny-resize-unavailable').show(); - } + function showFormatOptions(event) { + document.querySelector('#tiny-format-options').hidden = !event.target.checked; + } + + function showResizeInputs(event) { + document.querySelector('#tiny-resize-inputs').hidden = !event.target.checked; + } - var original_enabled = propOf('#tinypng_resize_original_enabled', 'checked'); - jQuery('#tinypng_resize_original_width, #tinypng_resize_original_height').each(function (i, el) { - el.disabled = !original_enabled; + /** + * Resizing, backups and metadata only apply to the original image, so their + * sections are hidden while the original size is not compressed. + */ + function showOriginalSettings(event) { + document.querySelectorAll('.tiny-resize-available').forEach(function(section) { + section.hidden = !event.target.checked; }); + document.querySelector('.tiny-resize-unavailable').hidden = event.target.checked; } - function updatePreserveSettings() { - if (propOf('#tinypng_sizes_0', 'checked')) { - jQuery('.tiny-preserve').show(); - } else { - jQuery('.tiny-preserve').hide(); - jQuery('#tinypng_preserve_data_creation').attr('checked', false); - jQuery('#tinypng_preserve_data_copyright').attr('checked', false); - jQuery('#tinypng_preserve_data_location').attr('checked', false); + function showAccountConnect() { + document.querySelector('.tiny-settings-connect').hidden = false; + document.querySelector('#tinypng_api_key').focus(); + } + + /** + * Replaces the account status placeholder with the status from the API. + */ + async function loadAccountStatus() { + const placeholder = document.querySelector('#tiny-account-status[data-state=pending]'); + if (!placeholder) { + return; + } + + const separator = ajaxurl.includes('?') ? '&' : '?'; + try { + const response = await fetch(`${ajaxurl}${separator}action=tiny_account_status`); + placeholder.outerHTML = await response.text(); + } catch (err) { + console.error(err); } } - function updateSettings() { - updateResizeSettings(); - updatePreserveSettings(); + /** + * Updates the credits per image under the sizes and in the sidebar. + */ + async function refreshCreditSummary() { + const totalSelectedSizes = document.querySelectorAll('input[name*=tinypng_sizes]:checked').length; + const compressWr2x = document.querySelector('#tinypng_sizes_wr2x')?.checked ?? false; + const compressOriginal = document.querySelector('#tinypng_sizes_0')?.checked ?? false; + const params = new URLSearchParams({ + action: 'tiny_image_sizes_notice', + image_sizes_selected: compressWr2x ? totalSelectedSizes - 1 : totalSelectedSizes, + _ajax_nonce: tinyCompress.nonce, + }); + if (compressOriginal && document.querySelector('#tinypng_resize_original_enabled').checked) { + params.set('resize_original', 'true'); + } + if (compressWr2x) { + params.set('compress_wr2x', 'true'); + } + if (document.querySelector('#tiny-convert').checked) { + params.set('convert', 'true'); + } + + const separator = ajaxurl.includes('?') ? '&' : '?'; + try { + const html = await fetch(`${ajaxurl}${separator}${params}`).then(r => r.text()); + const summary = new DOMParser().parseFromString(html, 'text/html'); + ['#tiny-image-sizes-notice', '#tiny-credits-per-image'].forEach(function(selector) { + document.querySelector(selector).innerHTML = summary.querySelector(selector).innerHTML; + }); + } catch (err) { + console.error(err); + } } var adminpage = ''; @@ -422,16 +399,6 @@ } } - function propOf(selector, property) { - if (typeof jQuery.fn.prop === 'function') { - /* Added in 1.6. Before jQuery 1.6, the .attr() method sometimes took - property values into account. */ - return jQuery(selector).prop(property); - } else { - return jQuery(selector).attr(property); - } - } - function setPropOf(selector, property, value) { if (typeof jQuery.fn.prop === 'function') { /* Added in 1.6. Before jQuery 1.6, the .attr() method sometimes took @@ -480,70 +447,23 @@ setTimeout(continueWhenActivated, 5000); break; case 'settings_page_tiny-onboarding-3': - eventOn('change', '#tiny-convert', (event) => { - document.querySelector('#tiny-format-options').hidden = !event.target.checked; - }); + eventOn('change', '#tiny-convert', showFormatOptions); break; case 'settings_page_tinify': - changeEnterKeyTarget('div.tiny-account-status div.create', '[data-tiny-action=create-key]'); - changeEnterKeyTarget('div.tiny-account-status div.update', '[data-tiny-action=update-key]'); - - eventOn('click', '[data-tiny-action=create-key]', submitKey); - eventOn('click', '[data-tiny-action=update-key]', submitKey); - eventOn('click', '#change-key', toggleChangeKey); - eventOn('click', '#cancel-change-key', toggleChangeKey); - - var target = jQuery('#tiny-account-status[data-state=pending]'); - if (target.length) { - jQuery.get(ajaxurl + (ajaxurl.indexOf( '?' ) > 0 ? '&' : '?') + 'action=tiny_account_status', function(data) { - target.replaceWith(data); - }); - } - - async function refreshSizeDescriptionNotice() { - const totalSelectedSizes = document.querySelectorAll('input[name*=tinypng_sizes]:checked').length; - const compressWr2x = document.querySelector('#tinypng_sizes_wr2x')?.checked ?? false; - const selectedCount = compressWr2x ? totalSelectedSizes - 1 : totalSelectedSizes; - - const separator = ajaxurl.includes('?') ? '&' : '?'; - let imageCountUrl = `${ajaxurl}${separator}action=tiny_image_sizes_notice&image_sizes_selected=${selectedCount}&_ajax_nonce=${tinyCompress.nonce}`; - - const resizeOriginalChecked = document.querySelector('#tinypng_resize_original_enabled')?.checked; - const compressOriginalChecked = document.querySelector('#tinypng_sizes_0')?.checked; - if (resizeOriginalChecked && compressOriginalChecked) { - imageCountUrl += '&resize_original=true'; - } - if (compressWr2x) { - imageCountUrl += '&compress_wr2x=true'; - } - try { - const sizeDescriptionHtml = await fetch(imageCountUrl).then(r => r.text()); - document.querySelector('#tiny-image-sizes-notice').innerHTML = sizeDescriptionHtml; - } catch (err) { - document.querySelector('#tiny-image-sizes-notice').innerHTML = ''; - console.error(err); - } - } - - eventOn('click', 'input[name*=tinypng_sizes], #tinypng_resize_original_enabled', refreshSizeDescriptionNotice); - eventOn('click', '#tinypng_auto_compress_enabled', updateSettings); - jQuery('#tinypng_sizes_0, #tinypng_resize_original_enabled').click(updateSettings); - updateSettings(); + changeEnterKeyTarget('#tinypng_api_key_name, #tinypng_api_key_email', '[data-tiny-action=create-key]'); + changeEnterKeyTarget('#tinypng_api_key', '[data-tiny-action=update-key]'); + eventOn('click', '[data-tiny-action=create-key]', submitAccountConnect); + eventOn('click', '[data-tiny-action=update-key]', submitAccountConnect); + eventOn('click', '[data-tiny-action=change-account]', showAccountConnect); + loadAccountStatus(); + eventOn('change', '#tiny-convert', showFormatOptions); + eventOn('change', '#tinypng_resize_original_enabled', showResizeInputs); + eventOn('change', '#tinypng_sizes_0', showOriginalSettings); + eventOn('change', 'input[name*=tinypng_sizes], #tinypng_resize_original_enabled, #tiny-convert', refreshCreditSummary); } jQuery('.tiny-notice a.tiny-dismiss').click(dismissNotice); jQuery(function() { jQuery('.tiny-notice.is-dismissible button').unbind('click').click(dismissNotice); }); - - - function onConvertChange(e) { - const newValue = e.target.checked; - if (newValue) { - jQuery('#tinypng_convert_convert_to').removeAttr('disabled'); - } else { - jQuery('#tinypng_convert_convert_to').attr('disabled', true); - } - } - jQuery('#tinypng_conversion_convert').on('change', onConvertChange); }).call(); diff --git a/src/views/account-status-connected.php b/src/views/account-status-connected.php index 776635fd..c94dff1d 100644 --- a/src/views/account-status-connected.php +++ b/src/views/account-status-connected.php @@ -10,145 +10,132 @@ * } * @var string $key The API key. */ + +// Only free accounts have a limited number of credits, including +// free accounts that bought a credit bundle. +$tiny_email = self::get_email_address(); +$tiny_remaining_credits = self::get_remaining_credits(); +$tiny_limited_credits = $status->ok && self::is_on_free_plan() && is_numeric( $tiny_remaining_credits ); +$tiny_remaining_credits = intval( $tiny_remaining_credits ); +$tiny_free_credits = Tiny_Config::MONTHLY_FREE_COMPRESSIONS; +$tiny_credits_url = 'https://tinify.com/checkout/credits?utm_source=wordpress-plugin&utm_medium=referral&utm_campaign=upgrade&utm_content=settings-credits'; +$tiny_api_url = 'https://tinify.com/checkout/api?utm_source=wordpress-plugin&utm_medium=referral&utm_campaign=upgrade&utm_content=settings-api'; ?> - diff --git a/src/views/account-status-connected.php b/src/views/account-status-connected.php index 5d486c35..bac4f708 100644 --- a/src/views/account-status-connected.php +++ b/src/views/account-status-connected.php @@ -102,6 +102,10 @@ diff --git a/test/integration/settings.spec.ts b/test/integration/settings.spec.ts index b202a3c4..510109a4 100644 --- a/test/integration/settings.spec.ts +++ b/test/integration/settings.spec.ts @@ -74,7 +74,7 @@ test.describe('settings', () => { }); test('show account connect to change the account', async () => { - await page.getByRole('button', { name: 'Change account' }).click(); + await page.getByRole('button', { name: 'Change API Key' }).click(); await expect(page.getByRole('heading', { name: 'Already have an account?' })).toBeVisible(); await expect(page.locator('#tinypng_api_key')).toBeFocused(); From d52336212b4a229d0d042e136f164cac3d7ff539 Mon Sep 17 00:00:00 2001 From: tijmen Date: Fri, 30 Oct 2026 20:12:41 +0100 Subject: [PATCH 33/45] show seperate tab for changing api key --- src/class-tiny-settings.php | 6 +- src/js/admin.js | 146 ++++++++++++++++--------- src/views/account-status-connect.php | 34 ++++-- src/views/account-status-connected.php | 3 +- 4 files changed, 122 insertions(+), 67 deletions(-) diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 36578ccd..928881c4 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -820,11 +820,13 @@ public function render_account_status() { * Renders the form to create a Tinify account or to connect one with an API key. */ public function render_account_connect() { - $can_create_key = $this->get_compressor()->can_create_key(); $user = wp_get_current_user(); $name = trim( $user->user_firstname . ' ' . $user->user_lastname ); $email = trim( $user->user_email ); - $has_api_key = (bool) $this->get_api_key(); + $api_key = $this->get_api_key(); + $has_api_key = (bool) $api_key; + $api_key_masked = str_repeat( '•', 8 ) . substr( $api_key, -4 ); + $show_create = ! $has_api_key; include __DIR__ . '/views/account-status-connect.php'; } diff --git a/src/js/admin.js b/src/js/admin.js index f2066e51..f69652b3 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -195,71 +195,108 @@ } /** - * Creates an account or connects an API key from the account connect view, - * then opens the page in its container's data-create-key-next or - * data-update-key-next. A failure is shown under the field it is about. + * Posts the account connect request for the clicked button and opens next + * when it worked. Returns the error message when it did not. */ - async function submitAccountConnect(event) { - event.preventDefault(); - - const button = event.target; - const column = button.closest('.tiny-account-connect-column'); - const spinner = column.querySelector('.spinner'); - const action = button.dataset.tinyAction === 'create-key' ? 'create' : 'update'; - const name = document.querySelector('#tinypng_api_key_name')?.value ?? ''; - const email = document.querySelector('#tinypng_api_key_email')?.value ?? ''; - const key = document.querySelector('#tinypng_api_key').value; - let field; - - if (action === 'update') { - field = document.querySelector('#tinypng_api_key').closest('.tiny-field'); - } else if (!name) { - field = document.querySelector('#tinypng_api_key_name').closest('.tiny-field'); - } else { - field = document.querySelector('#tinypng_api_key_email').closest('.tiny-field'); - } - - column.querySelectorAll('.tiny-field').forEach((f) => f.classList.remove('tiny-field-invalid')); - - if (action === 'update' && !key) { - field.classList.add('tiny-field-invalid'); - return; - } - - function showError(message) { - button.disabled = false; - spinner.classList.remove('is-active'); - if (action === 'create') { - field.querySelector('.tiny-field-error').textContent = message; - } - field.classList.add('tiny-field-invalid'); - }; - + async function postAccountConnect(button, params, next) { + const spinner = button.closest('.tiny-actions').querySelector('.spinner'); button.disabled = true; spinner.classList.add('is-active'); + let message; try { const response = await fetch(ajaxurl, { method: 'POST', - body: new URLSearchParams({ - _nonce: tinyCompress.nonce, - action: `tiny_settings_${action}_api_key`, - key, - name, - email, - }), + body: new URLSearchParams({ _nonce: tinyCompress.nonce, ...params }), }); if (!response.ok) { throw new Error(response.statusText); } const status = await response.json(); if (status.ok) { - window.location.href = button.closest('[data-create-key-next]').dataset[`${action}KeyNext`]; - } else { - showError(status.message); + window.location.href = next; + return null; } + message = status.message; } catch (err) { - showError('Something went wrong, try again soon'); + message = 'Something went wrong, try again soon'; + } + + button.disabled = false; + spinner.classList.remove('is-active'); + return message; + } + + /** + * Creates an account and opens data-create-key-next. A failure is shown + * under the name field when it is empty, otherwise under the email field. + */ + async function createAccount(event) { + event.preventDefault(); + + const button = event.target; + const nameInput = document.querySelector('#tinypng_api_key_name'); + const emailInput = document.querySelector('#tinypng_api_key_email'); + const nameField = nameInput.closest('.tiny-field'); + const emailField = emailInput.closest('.tiny-field'); + + nameField.classList.remove('tiny-field-invalid'); + emailField.classList.remove('tiny-field-invalid'); + + const message = await postAccountConnect(button, { + action: 'tiny_settings_create_api_key', + name: nameInput.value, + email: emailInput.value, + }, button.closest('[data-create-key-next]').dataset.createKeyNext); + + if (message) { + const field = nameInput.value ? emailField : nameField; + field.querySelector('.tiny-field-error').textContent = message; + field.classList.add('tiny-field-invalid'); + } + } + + /** + * Connects the API key in the key field and opens data-update-key-next. + */ + async function connectApiKey(event) { + event.preventDefault(); + + const button = event.target; + const keyInput = document.querySelector('#tinypng_api_key'); + const field = keyInput.closest('.tiny-field'); + + field.classList.remove('tiny-field-invalid'); + if (!keyInput.value) { + field.classList.add('tiny-field-invalid'); + return; + } + + const message = await postAccountConnect(button, { + action: 'tiny_settings_update_api_key', + key: keyInput.value, + }, button.closest('[data-update-key-next]').dataset.updateKeyNext); + + if (message) { + field.classList.add('tiny-field-invalid'); + } + } + + /** + * Disconnects the account and opens data-update-key-next. The server + * deletes the API key when it gets an empty one. + */ + async function disconnectApiKey(event) { + event.preventDefault(); + + const button = event.target; + const message = await postAccountConnect(button, { + action: 'tiny_settings_update_api_key', + key: '', + }, button.closest('[data-update-key-next]').dataset.updateKeyNext); + + if (message) { + window.alert(message); } } @@ -437,8 +474,8 @@ changeEnterKeyTarget('#tinypng_api_key_name, #tinypng_api_key_email', '[data-tiny-action=create-key]'); changeEnterKeyTarget('#tinypng_api_key', '[data-tiny-action=update-key]'); - eventOn('click', '[data-tiny-action=create-key]', submitAccountConnect); - eventOn('click', '[data-tiny-action=update-key]', submitAccountConnect); + eventOn('click', '[data-tiny-action=create-key]', createAccount); + eventOn('click', '[data-tiny-action=update-key]', connectApiKey); eventOn('change', '#tiny-convert', showFormatOptions); if (document.querySelector('[data-activated-next]')) { @@ -448,8 +485,9 @@ case 'settings_page_tinify': changeEnterKeyTarget('#tinypng_api_key_name, #tinypng_api_key_email', '[data-tiny-action=create-key]'); changeEnterKeyTarget('#tinypng_api_key', '[data-tiny-action=update-key]'); - eventOn('click', '[data-tiny-action=create-key]', submitAccountConnect); - eventOn('click', '[data-tiny-action=update-key]', submitAccountConnect); + eventOn('click', '[data-tiny-action=create-key]', createAccount); + eventOn('click', '[data-tiny-action=update-key]', connectApiKey); + eventOn('click', '[data-tiny-action=clear-key]', disconnectApiKey); eventOn('click', '[data-tiny-action=change-account]', showAccountConnect); loadAccountStatus(); eventOn('change', '#tiny-convert', showFormatOptions); diff --git a/src/views/account-status-connect.php b/src/views/account-status-connect.php index 06c40fb5..e591b2d0 100644 --- a/src/views/account-status-connect.php +++ b/src/views/account-status-connect.php @@ -2,10 +2,11 @@ /** * Create a Tinify account, or connect an existing one with its API key. * - * @var bool $can_create_key whether this server can register new accounts - * @var string $name full name to prefill - * @var string $email email address to prefill - * @var bool $has_api_key whether an account is connected already + * @var bool $show_create whether this server can register new accounts + * @var string $name full name to prefill + * @var string $email email address to prefill + * @var bool $has_api_key whether an account is connected already + * @var string $api_key_masked last characters of the connected API key, shown as placeholder */ $tiny_dashboard_link = sprintf( @@ -14,7 +15,7 @@ ); ?> From bd99ab83d7f50afcf2d1736e9ee456c4a60daf2e Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 2 Nov 2026 14:59:25 +0100 Subject: [PATCH 37/45] format --- src/views/onboarding-1.php | 4 ++-- src/views/onboarding-3.php | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php index 14a44e28..833b979e 100644 --- a/src/views/onboarding-1.php +++ b/src/views/onboarding-1.php @@ -8,13 +8,13 @@

diff --git a/src/views/onboarding-3.php b/src/views/onboarding-3.php index a6b8780a..9d0ae340 100644 --- a/src/views/onboarding-3.php +++ b/src/views/onboarding-3.php @@ -38,10 +38,14 @@ ?> settings->is_on_free_plan() ) { ?> -

+

+ ); + ?> +

From 884217fba87ae3c053d7823e98359929d6f39e9a Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 2 Nov 2026 15:05:55 +0100 Subject: [PATCH 38/45] formatting --- src/views/onboarding-1.php | 4 ++-- src/views/onboarding-3.php | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/views/onboarding-1.php b/src/views/onboarding-1.php index 833b979e..825ed2a0 100644 --- a/src/views/onboarding-1.php +++ b/src/views/onboarding-1.php @@ -9,12 +9,12 @@

diff --git a/src/views/onboarding-3.php b/src/views/onboarding-3.php index 9d0ae340..916d40fd 100644 --- a/src/views/onboarding-3.php +++ b/src/views/onboarding-3.php @@ -41,8 +41,9 @@

From 407212473aa184ddd470940ce460902ab334c875 Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 2 Nov 2026 15:29:37 +0100 Subject: [PATCH 39/45] move new admin.css rules into the old settings slot --- src/css/admin.css | 394 +++++++++++++++++++++++----------------------- 1 file changed, 197 insertions(+), 197 deletions(-) diff --git a/src/css/admin.css b/src/css/admin.css index 35944851..d847faf3 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -42,203 +42,6 @@ div.misc-pub-section.tiny-compress-images { padding-right: 100px; } -/* Media library */ -.tiny-compress-images .spinner.hidden { - display: none; - visibility: hidden; - float: none; - margin-top: 0; -} - -.tiny-compress-images div.spinner { - display: inline-block; - visibility: visible; - float: right; - margin-top: 4px; -} - -.tiny-compress-images span.spinner { - visibility: visible; - float: none; - margin: 0; -} - -.tiny-compress-images span.icon { - display: inline-block; - margin-left: -4px; - padding-right: 4px; -} - -.tiny-compress-images span.dashicons-before::before { - margin-left: -2px; - font-size: 23px; -} - -.tiny-compress-images .details-container { - overflow: auto; -} - -.tiny-compress-images .details-container button { - display: inline-block; - margin-bottom: 5px; -} - -.tiny-compress-images .details-container button:first-of-type { - margin-left: 20px; -} - -.tiny-compress-images .details-container span.icon { - position: absolute; -} - -.tiny-compress-images .details-container .message { - display: inline-block; - margin-left: 20px; -} - -.tiny-compress-images .details-container a.message { - margin-bottom: 5px; -} - -.tiny-compress-images span.success { - color: #30d030; -} - -.tiny-compress-images span.warning { - color: #f0ad4e; -} - -.tiny-compress-images span.error { - color: #d54e21; -} - -.tiny-compress-images span.alert { - color: gray; -} - -#tiny-compress-status .spinner, -#tiny-compress-savings .spinner { - visibility: visible; - display: inline; - float: left; - width: 20px; - height: 20px; - margin: 0; -} - -.tiny-compression-details { - padding: 10px 0; -} - -.tiny-compression-details table { - width: 100%; - border: 1px solid #e5e5e5; - border-collapse: collapse; - white-space: nowrap; -} - -.tiny-compression-details table tr.even { - background-color: #f9f9f9; -} - -.tiny-compression-details table td { - padding: 6px 10px; -} - -.tiny-compression-details table th { - padding: 8px 10px; - border-bottom: 1px solid #e5e5e5; - font-size: 14px; -} - -.tiny-compression-details table tfoot { - border-top: 1px solid #e5e5e5; -} - -.tiny-compression-details table tfoot td { - padding: 8px 10px; -} - -.tiny-compress-images h4 { - margin: 5px 0; -} - -.tiny-dialog { - padding: 20px; - border: 1px solid #ccd0d4; - border-radius: 4px; - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); -} - -.tiny-dialog::backdrop { - background: rgba(0, 0, 0, 0.7); -} - -.tiny-details-dialog { - box-sizing: border-box; - width: min(700px, calc(100vw - 32px)); - max-height: calc(100vh - 64px); - overflow: auto; - text-align: left; - white-space: normal; -} - -.tiny-dialog-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; -} - -.tiny-dialog-close { - color: #646970; - cursor: pointer; -} - -.tiny-dialog-close:hover, -.tiny-dialog-close:focus { - color: #135e96; -} - -.tiny-dialog-error { - color: #dc3232; -} - -.tiny-dialog-actions { - display: flex; - gap: 10px; - justify-content: flex-end; -} - -/* Overrides .tiny-compress-images span.spinner; admin.js shows it while busy. */ -.tiny-dialog-actions span.spinner { - visibility: hidden; -} - -.tiny-dialog-title { - font-size: 1.3rem; -} - -.tiny-compression-details .tiny-card { - display: flex; - justify-content: space-between; - align-items: center; - padding: 5px 20px; - background: #F1F7FF; - border: 1px solid #e1e1e1; -} - -.tiny-card a.button { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.tiny-icon-backup { - flex-shrink: 0; - fill: currentcolor; -} - /* Account connect */ .tiny-account-connect { box-sizing: border-box; @@ -962,4 +765,201 @@ div.misc-pub-section.tiny-compress-images { .tiny-credit-option+.tiny-credit-option { padding-top: 12px; border-top: 1px solid var(--tiny-border-default); +} + +/* Media library */ +.tiny-compress-images .spinner.hidden { + display: none; + visibility: hidden; + float: none; + margin-top: 0; +} + +.tiny-compress-images div.spinner { + display: inline-block; + visibility: visible; + float: right; + margin-top: 4px; +} + +.tiny-compress-images span.spinner { + visibility: visible; + float: none; + margin: 0; +} + +.tiny-compress-images span.icon { + display: inline-block; + margin-left: -4px; + padding-right: 4px; +} + +.tiny-compress-images span.dashicons-before::before { + margin-left: -2px; + font-size: 23px; +} + +.tiny-compress-images .details-container { + overflow: auto; +} + +.tiny-compress-images .details-container button { + display: inline-block; + margin-bottom: 5px; +} + +.tiny-compress-images .details-container button:first-of-type { + margin-left: 20px; +} + +.tiny-compress-images .details-container span.icon { + position: absolute; +} + +.tiny-compress-images .details-container .message { + display: inline-block; + margin-left: 20px; +} + +.tiny-compress-images .details-container a.message { + margin-bottom: 5px; +} + +.tiny-compress-images span.success { + color: #30d030; +} + +.tiny-compress-images span.warning { + color: #f0ad4e; +} + +.tiny-compress-images span.error { + color: #d54e21; +} + +.tiny-compress-images span.alert { + color: gray; +} + +#tiny-compress-status .spinner, +#tiny-compress-savings .spinner { + visibility: visible; + display: inline; + float: left; + width: 20px; + height: 20px; + margin: 0; +} + +.tiny-compression-details { + padding: 10px 0; +} + +.tiny-compression-details table { + width: 100%; + border: 1px solid #e5e5e5; + border-collapse: collapse; + white-space: nowrap; +} + +.tiny-compression-details table tr.even { + background-color: #f9f9f9; +} + +.tiny-compression-details table td { + padding: 6px 10px; +} + +.tiny-compression-details table th { + padding: 8px 10px; + border-bottom: 1px solid #e5e5e5; + font-size: 14px; +} + +.tiny-compression-details table tfoot { + border-top: 1px solid #e5e5e5; +} + +.tiny-compression-details table tfoot td { + padding: 8px 10px; +} + +.tiny-compress-images h4 { + margin: 5px 0; +} + +.tiny-dialog { + padding: 20px; + border: 1px solid #ccd0d4; + border-radius: 4px; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); +} + +.tiny-dialog::backdrop { + background: rgba(0, 0, 0, 0.7); +} + +.tiny-details-dialog { + box-sizing: border-box; + width: min(700px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + overflow: auto; + text-align: left; + white-space: normal; +} + +.tiny-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.tiny-dialog-close { + color: #646970; + cursor: pointer; +} + +.tiny-dialog-close:hover, +.tiny-dialog-close:focus { + color: #135e96; +} + +.tiny-dialog-error { + color: #dc3232; +} + +.tiny-dialog-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +/* Overrides .tiny-compress-images span.spinner; admin.js shows it while busy. */ +.tiny-dialog-actions span.spinner { + visibility: hidden; +} + +.tiny-dialog-title { + font-size: 1.3rem; +} + +.tiny-compression-details .tiny-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 20px; + background: #F1F7FF; + border: 1px solid #e1e1e1; +} + +.tiny-card a.button { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.tiny-icon-backup { + flex-shrink: 0; + fill: currentcolor; } \ No newline at end of file From 39320b6284142a7ddc265ec01f13692680c55e6b Mon Sep 17 00:00:00 2001 From: tijmen Date: Mon, 2 Nov 2026 16:30:45 +0100 Subject: [PATCH 40/45] show create account form while api key is pending --- src/class-tiny-settings.php | 2 +- test/integration/onboarding.spec.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 928881c4..2a6f6d88 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -824,7 +824,7 @@ public function render_account_connect() { $name = trim( $user->user_firstname . ' ' . $user->user_lastname ); $email = trim( $user->user_email ); $api_key = $this->get_api_key(); - $has_api_key = (bool) $api_key; + $has_api_key = $api_key && ! $this->get_api_key_pending(); $api_key_masked = str_repeat( '•', 8 ) . substr( $api_key, -4 ); $show_create = ! $has_api_key; include __DIR__ . '/views/account-status-connect.php'; diff --git a/test/integration/onboarding.spec.ts b/test/integration/onboarding.spec.ts index d1564946..e3c082fe 100644 --- a/test/integration/onboarding.spec.ts +++ b/test/integration/onboarding.spec.ts @@ -27,4 +27,21 @@ test.describe('onboarding', () => { await expect(page.getByRole('heading', { name: 'Connected as test@example.com' })).toBeVisible(); await expect(page.getByText('Free plan, 500 credits a month.')).toBeVisible(); }); + + test('change the email address before confirming it', async () => { + await setAPIKey(page, ''); + await page.goto('/wp-admin/options-general.php?page=tiny-onboarding&step=1'); + await page.locator('#tinypng_api_key_name').fill('John'); + await page.locator('#tinypng_api_key_email').fill('john@example.com'); + await page.getByRole('button', { name: 'Create account' }).click(); + await page.waitForURL('**step=2**'); + + await page.getByRole('link', { name: 'Change email address' }).click(); + await page.locator('#tinypng_api_key_name').fill('Jane'); + await page.locator('#tinypng_api_key_email').fill('jane@example.com'); + await page.getByRole('button', { name: 'Create account' }).click(); + await page.waitForURL('**step=2**'); + + await expect(page.getByText('Click the link we sent to jane@example.com.')).toBeVisible(); + }); }); From 818c1020a87f8655ee4c46917457c9406f4372eb Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 3 Nov 2026 09:34:55 +0100 Subject: [PATCH 41/45] scope new card style to the settings page --- src/css/admin.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/css/admin.css b/src/css/admin.css index d847faf3..7f87a708 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -301,7 +301,7 @@ div.misc-pub-section.tiny-compress-images { } .tiny-onboarding-card, -.tiny-card { +.tiny-settings .tiny-card { box-sizing: border-box; width: 100%; background: var(--tiny-surface-primary); From da3b7bc46a8e6447c7643c4100ec77d296c8d3c0 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 3 Nov 2026 09:41:46 +0100 Subject: [PATCH 42/45] drop settings-updated redirect wait from dashboard widget test --- test/integration/dashboardwidget.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/integration/dashboardwidget.spec.ts b/test/integration/dashboardwidget.spec.ts index 6850042d..6571104d 100644 --- a/test/integration/dashboardwidget.spec.ts +++ b/test/integration/dashboardwidget.spec.ts @@ -11,7 +11,6 @@ const widget = () => page.locator('#tinypng_dashboard_widget'); // so wait for that before the dashboard reads them. async function setAccount(key: string) { await setAPIKey(page, key); - await page.waitForURL(/settings-updated=true/); await page.waitForLoadState('networkidle'); } From 60a3ea885b527305247969284bf9a537a9560838 Mon Sep 17 00:00:00 2001 From: tijmen Date: Tue, 3 Nov 2026 11:45:25 +0100 Subject: [PATCH 43/45] show free credits for pay per compression accounts --- src/class-tiny-dashboard.php | 12 ++++++ src/views/account-status-connected.php | 54 ++++++++++++++++++------ src/views/dashboard-widget.php | 21 +++++++-- src/views/onboarding-3.php | 10 +++++ test/integration/dashboardwidget.spec.ts | 11 +++++ test/integration/onboarding.spec.ts | 10 +++++ test/integration/settings.spec.ts | 16 +++++-- test/mock-tinypng-webservice/keys.php | 6 ++- test/unit/Tiny_Dashboard_Test.php | 31 ++++++++++++++ 9 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/class-tiny-dashboard.php b/src/class-tiny-dashboard.php index 196f3339..cb450a41 100644 --- a/src/class-tiny-dashboard.php +++ b/src/class-tiny-dashboard.php @@ -128,6 +128,17 @@ public function get_widget_data( $optimization_stats ) { is_numeric( $remaining_credits ); $remaining_credits = $has_limited_credits ? intval( $remaining_credits ) : null; + // Paid accounts get the same free credits each month and pay per + // compression once those are used. + $free_credits_left = null; + if ( ! $this->settings->is_on_free_plan() ) { + $compression_count = intval( $this->settings->get_compression_count() ); + $free_credits_left = max( + 0, + Tiny_Config::MONTHLY_FREE_COMPRESSIONS - $compression_count + ); + } + // How many of the remaining images the credits still cover. $images_affordable = 0; if ( $has_limited_credits && $images_remaining > 0 && $estimated_credit_use > 0 ) { @@ -169,6 +180,7 @@ public function get_widget_data( $optimization_stats ) { 'percentage' => $percentage, 'estimated_credit_use' => $estimated_credit_use, 'remaining_credits' => $remaining_credits, + 'free_credits_left' => $free_credits_left, 'seconds_left' => $seconds_left, 'email_address' => $this->settings->get_email_address(), 'auto_compress' => $this->settings->auto_compress_enabled(), diff --git a/src/views/account-status-connected.php b/src/views/account-status-connected.php index 8242cdfb..d1f01bfe 100644 --- a/src/views/account-status-connected.php +++ b/src/views/account-status-connected.php @@ -11,14 +11,18 @@ * @var string $key The API key. */ -// Only free accounts have a limited number of credits, including -// free accounts that bought a credit bundle. +// Every account gets free credits each month. Only free accounts have a +// limited number of credits, including free accounts that bought a credit +// bundle. Paid accounts pay per compression once the free credits are used. $tiny_email = self::get_email_address(); +$tiny_on_free_plan = self::is_on_free_plan(); $tiny_remaining_credits = self::get_remaining_credits(); -$tiny_limited_credits = $status->ok && self::is_on_free_plan() && is_numeric( $tiny_remaining_credits ); -$tiny_remaining_credits = intval( $tiny_remaining_credits ); +$tiny_limited_credits = $status->ok && $tiny_on_free_plan && is_numeric( $tiny_remaining_credits ); +$tiny_show_credits = $tiny_limited_credits || ( $status->ok && ! $status->pending && ! $tiny_on_free_plan ); $tiny_free_credits = Tiny_Config::MONTHLY_FREE_COMPRESSIONS; -$tiny_free_credits_left = min( $tiny_remaining_credits, max( 0, $tiny_free_credits - intval( self::get_compression_count() ) ) ); +$tiny_free_credits_left = max( 0, $tiny_free_credits - intval( self::get_compression_count() ) ); +$tiny_remaining_credits = $tiny_on_free_plan ? intval( $tiny_remaining_credits ) : $tiny_free_credits_left; +$tiny_free_credits_left = min( $tiny_remaining_credits, $tiny_free_credits_left ); $tiny_bundle_credits = $tiny_remaining_credits - $tiny_free_credits_left; $tiny_credits_bar_total = $tiny_free_credits + $tiny_bundle_credits; $tiny_dashboard_url = 'https://tinify.com/dashboard/api'; @@ -82,11 +86,17 @@ - +
-

+

- +

@@ -95,11 +105,19 @@

0 ) { ?> @@ -150,7 +168,15 @@
- + + +
diff --git a/src/views/dashboard-widget.php b/src/views/dashboard-widget.php index 1c3195d6..e8687c85 100644 --- a/src/views/dashboard-widget.php +++ b/src/views/dashboard-widget.php @@ -45,7 +45,7 @@ $has_library_bar = ! in_array( $state, array( 'pending', 'empty' ), true ); $has_credits_view = in_array( $state, array( 'credits_short', 'out_of_credits' ), true ); -$has_credits_line = null !== $widget['remaining_credits'] && +$has_credits_line = ( null !== $widget['remaining_credits'] || null !== $widget['free_credits_left'] ) && ! in_array( $state, array( 'not_connected', 'pending' ), true ); $seconds_left_text = ''; @@ -277,7 +277,15 @@ class="tiny-widget-panda"
- + + +
@@ -303,7 +311,14 @@ class="tiny-widget-panda" ?>

+ settings->get_paying_state() ) { ?> +

+ +

diff --git a/test/integration/dashboardwidget.spec.ts b/test/integration/dashboardwidget.spec.ts index 6571104d..c5c0d525 100644 --- a/test/integration/dashboardwidget.spec.ts +++ b/test/integration/dashboardwidget.spec.ts @@ -109,6 +109,17 @@ test.describe('dashboardwidget', () => { await expect(page.locator('#compression-progress-bar')).toHaveText('0 / 1 (0%)'); }); + test('paid accounts pay per compression after their free credits', async () => { + await setAccount('PAIDUSED123'); + await setCompressionTiming(page, 'manual'); + await uploadImages(page, 'input-example.jpg'); + + await page.goto('/wp-admin/index.php'); + + await expect(widget().getByText('Ready to compress')).toBeVisible(); + await expect(widget().getByText('0 of 500 free credits left, then pay per compression')).toBeVisible(); + }); + test('upgrade shows how to get more credits', async () => { await setAccount('INSUFFICIENTCREDITS123'); await setCompressionTiming(page, 'auto'); diff --git a/test/integration/onboarding.spec.ts b/test/integration/onboarding.spec.ts index e3c082fe..3fae9e76 100644 --- a/test/integration/onboarding.spec.ts +++ b/test/integration/onboarding.spec.ts @@ -28,6 +28,16 @@ test.describe('onboarding', () => { await expect(page.getByText('Free plan, 500 credits a month.')).toBeVisible(); }); + test('show the free credits of a paid account', async () => { + await setAPIKey(page, ''); + await page.goto('/wp-admin/options-general.php?page=tiny-onboarding&step=1'); + await page.locator('#tinypng_api_key').fill('PAID123'); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); + await page.waitForURL('**step=3**'); + + await expect(page.getByText('Pay per compression, after 500 free credits a month.')).toBeVisible(); + }); + test('change the email address before confirming it', async () => { await setAPIKey(page, ''); await page.goto('/wp-admin/options-general.php?page=tiny-onboarding&step=1'); diff --git a/test/integration/settings.spec.ts b/test/integration/settings.spec.ts index ee70c887..1955e696 100644 --- a/test/integration/settings.spec.ts +++ b/test/integration/settings.spec.ts @@ -83,8 +83,9 @@ test.describe('settings', () => { test('show credits for free accounts', async () => { await setAPIKey(page, 'LIMIT123'); - await expect(page.getByText('500 free credits')).toBeVisible(); + await expect(page.getByText('500 free credits', { exact: true })).toBeVisible(); await expect(page.getByText('Get more credits')).toBeVisible(); + await expect(page.getByText('Keep 500 free credits a month, then pay for what you compress.')).toBeVisible(); }); test('use free credits before bought credits', async () => { @@ -95,10 +96,19 @@ test.describe('settings', () => { await expect(page.getByText('1,000 bundle credits (never expire)')).toBeVisible(); }); - test('not show credits for paid accounts', async () => { + test('show free credits for paid accounts', async () => { await setAPIKey(page, 'PAID123'); - await expect(page.getByText('test@example.com')).toBeVisible(); + await expect(page.getByText('500 free credits left')).toBeVisible(); + await expect(page.getByText('500 free credits, then pay per compression')).toBeVisible(); + await expect(page.getByText('Get more credits')).not.toBeVisible(); + }); + + test('pay per compression once free credits are used', async () => { + await setAPIKey(page, 'PAIDUSED123'); + + await expect(page.getByText('0 free credits left')).toBeVisible(); + await expect(page.getByText('0 free credits, then pay per compression')).toBeVisible(); await expect(page.getByText('Get more credits')).not.toBeVisible(); }); diff --git a/test/mock-tinypng-webservice/keys.php b/test/mock-tinypng-webservice/keys.php index 9bf8034f..02d75826 100644 --- a/test/mock-tinypng-webservice/keys.php +++ b/test/mock-tinypng-webservice/keys.php @@ -13,10 +13,10 @@ function mock_key_details_free_request($remaining = 500, $count = 0) { return '{}'; } -function mock_key_details_paid_request() { +function mock_key_details_paid_request($count = 0) { header("HTTP/1.1 200 OK"); header("Content-Type: application/json; charset=utf-8"); - header("Compression-Count: 0"); + header("Compression-Count: " . $count); header("Email-Address: test@example.com"); header("Paying-State: paid"); @@ -54,6 +54,8 @@ function mock_key_creation_request() { echo mock_key_details_free_request(1300, 200); } elseif ($key == "NOCREDITS123") { echo mock_key_details_free_request(0); + } elseif ($key == "PAIDUSED123") { + echo mock_key_details_paid_request(700); } else { echo mock_key_details_paid_request(); } diff --git a/test/unit/Tiny_Dashboard_Test.php b/test/unit/Tiny_Dashboard_Test.php index 3c64ef24..71c26962 100644 --- a/test/unit/Tiny_Dashboard_Test.php +++ b/test/unit/Tiny_Dashboard_Test.php @@ -327,6 +327,37 @@ public function test_images_affordable_is_zero_without_limited_credits() $this->assertSame(0, $data['images_affordable']); } + public function test_free_credits_left_is_unknown_for_free_accounts() + { + $this->connect(300); + + $data = $this->widget_data($this->stats(20, 15)); + + $this->assertNull($data['free_credits_left']); + } + + public function test_paid_accounts_use_their_free_credits_first() + { + $this->connect(false, false); + $this->settings->method('get_compression_count')->willReturn(200); + + $data = $this->widget_data($this->stats(20, 15)); + + $this->assertSame(300, $data['free_credits_left']); + $this->assertNull($data['remaining_credits']); + } + + public function test_paid_accounts_keep_compressing_after_their_free_credits() + { + $this->connect(false, false); + $this->settings->method('get_compression_count')->willReturn(700); + + $data = $this->widget_data($this->stats(20, 15, 0, 0, 30)); + + $this->assertSame(0, $data['free_credits_left']); + $this->assertSame('ready', $data['state']); + } + public function test_seconds_left_spreads_compressions_over_the_workers() { $this->connect(); From e188a3b44e3dd43b9930e0fd951f2474a97e1234 Mon Sep 17 00:00:00 2001 From: tijmen Date: Fri, 6 Nov 2026 09:07:17 +0100 Subject: [PATCH 44/45] inline and reuse compression timing settings --- src/class-tiny-onboarding.php | 10 +++-- src/views/onboarding-3.php | 16 +------- src/views/settings-compression-timing.php | 45 +++++++++++++++++++++++ src/views/settings.php | 39 +------------------- 4 files changed, 54 insertions(+), 56 deletions(-) create mode 100644 src/views/settings-compression-timing.php diff --git a/src/class-tiny-onboarding.php b/src/class-tiny-onboarding.php index 881511a3..fb0abee4 100644 --- a/src/class-tiny-onboarding.php +++ b/src/class-tiny-onboarding.php @@ -221,10 +221,12 @@ public function save_settings() { ); } - update_option( - self::get_prefixed_name( 'compression_timing' ), - isset( $_POST['auto_compress'] ) ? 'background' : 'manual' - ); + $timing_name = self::get_prefixed_name( 'compression_timing' ); + $timing = isset( $_POST[ $timing_name ] ) ? + sanitize_key( wp_unslash( $_POST[ $timing_name ] ) ) : ''; + if ( in_array( $timing, array( 'background', 'auto', 'manual' ), true ) ) { + update_option( $timing_name, $timing ); + } update_option( self::get_prefixed_name( 'convert_format' ), $convert_format ); $next = isset( $_POST['next'] ) ? sanitize_key( wp_unslash( $_POST['next'] ) ) : ''; diff --git a/src/views/onboarding-3.php b/src/views/onboarding-3.php index 7d42fd7c..1210e3c2 100644 --- a/src/views/onboarding-3.php +++ b/src/views/onboarding-3.php @@ -1,9 +1,9 @@ settings->refresh_api_key_pending(); -$tiny_auto_compress = $this->settings->auto_compress_enabled(); $tiny_stats = Tiny_Bulk_Optimization::get_optimization_statistics( $this->settings ); $tiny_uncompressed = count( $tiny_stats['available-for-optimization'] ); $tiny_confirm_email = 'api-key' !== filter_input( INPUT_GET, 'path' ); +$tiny_timing = $this->settings->get_compression_timing(); ?> @@ -74,19 +74,7 @@
-
-
-

-
- -
+ settings->render_modern_formats(); ?> diff --git a/src/views/settings-compression-timing.php b/src/views/settings-compression-timing.php new file mode 100644 index 00000000..9680ec8d --- /dev/null +++ b/src/views/settings-compression-timing.php @@ -0,0 +1,45 @@ + +
+
+

+
+ +
+

+ — + +

+
+ + + + +
diff --git a/src/views/settings.php b/src/views/settings.php index f478da3a..b9352bb3 100644 --- a/src/views/settings.php +++ b/src/views/settings.php @@ -5,7 +5,6 @@ $tiny_wr2x = $this->compress_wr2x_images(); $tiny_convert = $this->get_conversion_enabled(); $tiny_logging_name = self::get_prefixed_name( 'logging_enabled' ); -$tiny_timing_name = self::get_prefixed_name( 'compression_timing' ); $tiny_timing = $this->get_compression_timing(); ?>
@@ -25,43 +24,7 @@ -
-
-

-
- background_compress_enabled() && Tiny_AS3CF::is_active() && Tiny_AS3CF::remove_local_files_setting_enabled() ) { ?> -
-

- — - -

-
- - - - -
+
From c3d299dbba6800ab7103318500470c94b6c6cd65 Mon Sep 17 00:00:00 2001 From: tijmen Date: Fri, 6 Nov 2026 10:20:52 +0100 Subject: [PATCH 45/45] re-add link, is used in emails --- src/class-tiny-settings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 2a6f6d88..6ebfc15a 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -901,6 +901,7 @@ public function create_api_key() { $options = array( 'name' => $name, 'identifier' => $identifier, + 'link' => $link, ); $compressor->create_key( $email, $options );