From 19ff397d7cef72a834f53ddd80b556e0f5eb78bf Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Thu, 27 Aug 2026 16:30:54 +0200 Subject: [PATCH 1/4] Add sunset notice with downloadable certificate Show a notice that support for the plugin is ending, on the Progress Planner dashboard and on the plugins overview page. The notice thanks users and links to more information, and lets them download a printable single-page A4 certificate showing their site, how long they have used Progress Planner, and all completed badges. The notice is hidden when the pp-hosts companion plugin is installed or active, or when a branding ID is defined, since support continues for hosted users. A progress_planner_show_sunset_notice filter is available as an escape hatch. Co-Authored-By: Claude Fable 5 --- assets/css/admin.css | 16 ++ classes/admin/class-sunset-notice.php | 192 ++++++++++++++++++ classes/class-base.php | 1 + tests/phpunit/test-class-sunset-notice.php | 167 ++++++++++++++++ views/sunset-certificate.php | 216 +++++++++++++++++++++ views/sunset-notice.php | 22 +++ 6 files changed, 614 insertions(+) create mode 100644 classes/admin/class-sunset-notice.php create mode 100644 tests/phpunit/test-class-sunset-notice.php create mode 100644 views/sunset-certificate.php create mode 100644 views/sunset-notice.php diff --git a/assets/css/admin.css b/assets/css/admin.css index 58938bef6..c8862528c 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -324,6 +324,22 @@ button.prpl-info-icon { } } +/*------------------------------------*\ + Sunset notice. +\*------------------------------------*/ +.prpl-wrap .prpl-sunset-notice { + border-left: 4px solid var(--prpl-background-banner); + + h1 { + color: var(--prpl-color-headings); + margin-top: 0; + } + + p { + max-width: 680px; + } +} + /*------------------------------------*\ Buttons \*------------------------------------*/ diff --git a/classes/admin/class-sunset-notice.php b/classes/admin/class-sunset-notice.php new file mode 100644 index 000000000..4bcde56ee --- /dev/null +++ b/classes/admin/class-sunset-notice.php @@ -0,0 +1,192 @@ +should_show() ) { + return; + } + + \add_action( 'progress_planner_admin_page_header_before', [ $this, 'the_dashboard_notice' ] ); + \add_action( 'after_plugin_row_' . \plugin_basename( \PROGRESS_PLANNER_FILE ), [ $this, 'the_plugin_row_notice' ] ); + \add_action( 'admin_post_' . self::CERTIFICATE_ACTION, [ $this, 'render_certificate' ] ); + } + + /** + * Whether the sunset notice should be shown. + * + * The notice only applies to installs coming from wordpress.org or FAIR. + * It is hidden when the pp-hosts companion plugin is active (constants or + * class detection), when a host defines a branding ID, or when pp-hosts + * is installed but not (yet) activated. + * + * @return bool + */ + public function should_show() { + $show = ! ( + \defined( 'PP_HOSTS_FILE' ) + || \defined( 'PROGRESS_PLANNER_BRANDING_ID' ) + || \class_exists( 'PP_Hosts\Updater' ) + || \progress_planner()->get_plugin_installer()->is_plugin_installed( 'pp-hosts' ) + ); + + /** + * Filter whether the sunset notice should be shown. + * + * @param bool $show Whether the notice should be shown. + */ + return (bool) \apply_filters( 'progress_planner_show_sunset_notice', $show ); + } + + /** + * Get the notice message, shared by both surfaces. + * + * Contains an anchor tag, so it should be escaped with `wp_kses_post` on output. + * + * @return string + */ + public function get_message() { + return \sprintf( + /* translators: %1$s: opening tag, %2$s: closing tag. */ + \esc_html__( 'Support and updates for the free Progress Planner plugin are ending. We are incredibly grateful for your support over the years — thank you for improving your website with us! %1$sLearn more about what this means for your site%2$s.', 'progress-planner' ), + '', + '' + ); + } + + /** + * Get the certificate invitation sentence. + * + * Contains an anchor tag, so it should be escaped with `wp_kses_post` on output. + * + * @return string + */ + public function get_certificate_message() { + return \sprintf( + /* translators: %1$s: opening tag, %2$s: closing tag. */ + \esc_html__( 'As a keepsake, you can %1$sdownload a certificate%2$s with your site\'s achievements.', 'progress-planner' ), + '', + '' + ); + } + + /** + * Get the URL of the certificate page. + * + * @return string + */ + public function get_certificate_url() { + return \admin_url( 'admin-post.php?action=' . self::CERTIFICATE_ACTION ); + } + + /** + * Get all completed badges, across all badge contexts. + * + * @return \Progress_Planner\Badges\Badge[] + */ + public function get_completed_badges() { + $completed = []; + foreach ( [ 'content', 'maintenance', 'monthly_flat' ] as $context ) { + foreach ( \progress_planner()->get_badges()->get_badges( $context ) as $badge ) { + $progress = $badge->get_progress(); + if ( isset( $progress['progress'] ) && 100 === (int) $progress['progress'] ) { + $completed[] = $badge; + } + } + } + return $completed; + } + + /** + * Render the standalone certificate page and exit. + * + * Hooked to admin-post, so this renders outside the WP admin chrome. + * + * @return void + */ + public function render_certificate() { + if ( ! \current_user_can( 'edit_others_posts' ) ) { + \wp_die( \esc_html__( 'You do not have permission to view this page.', 'progress-planner' ) ); + } + $this->the_certificate(); + exit; + } + + /** + * Print the certificate page markup. + * + * @return void + */ + public function the_certificate() { + \progress_planner()->the_view( 'sunset-certificate.php' ); + } + + /** + * Print the notice banner on the Progress Planner dashboard. + * + * @return void + */ + public function the_dashboard_notice() { + \progress_planner()->the_view( 'sunset-notice.php' ); + } + + /** + * Print a notice row below the plugin's row on the plugins overview page. + * + * @param string $plugin_file Path to the plugin file relative to the plugins directory. + * + * @return void + */ + public function the_plugin_row_notice( $plugin_file ) { + if ( ! \current_user_can( 'activate_plugins' ) ) { + return; + } + + $prpl_colspan = 4; + if ( \function_exists( '_get_list_table' ) ) { + $prpl_colspan = \_get_list_table( 'WP_Plugins_List_Table', [ 'screen' => 'plugins' ] )->get_column_count(); + } + + $prpl_is_active = \function_exists( 'is_plugin_active' ) && \is_plugin_active( $plugin_file ); + ?> + + +
+

get_message() . ' ' . $this->get_certificate_message() ); ?>

+
+ + + get_admin__page(); $this->get_admin__tour(); + $this->get_admin__sunset_notice(); // Dont add the widget if the privacy policy is not accepted. if ( true === $this->is_privacy_policy_accepted() ) { diff --git a/tests/phpunit/test-class-sunset-notice.php b/tests/phpunit/test-class-sunset-notice.php new file mode 100644 index 000000000..0c875330c --- /dev/null +++ b/tests/phpunit/test-class-sunset-notice.php @@ -0,0 +1,167 @@ +assertTrue( $notice->should_show() ); + } + + /** + * Test that hooks are registered on instantiation in a clean install. + * + * @return void + */ + public function test_hooks_registered_by_default() { + $notice = new Sunset_Notice(); + $this->assertNotFalse( \has_action( 'progress_planner_admin_page_header_before', [ $notice, 'the_dashboard_notice' ] ) ); + $this->assertNotFalse( \has_action( 'after_plugin_row_' . \plugin_basename( \PROGRESS_PLANNER_FILE ), [ $notice, 'the_plugin_row_notice' ] ) ); + } + + /** + * Test that hooks are not registered when the filter hides the notice. + * + * @return void + */ + public function test_hooks_not_registered_when_filtered_out() { + \add_filter( 'progress_planner_show_sunset_notice', '__return_false' ); + $notice = new Sunset_Notice(); + $this->assertFalse( $notice->should_show() ); + $this->assertFalse( \has_action( 'progress_planner_admin_page_header_before', [ $notice, 'the_dashboard_notice' ] ) ); + $this->assertFalse( \has_action( 'after_plugin_row_' . \plugin_basename( \PROGRESS_PLANNER_FILE ), [ $notice, 'the_plugin_row_notice' ] ) ); + } + + /** + * Test that the message contains the learn-more link. + * + * @return void + */ + public function test_message_contains_learn_more_link() { + $notice = new Sunset_Notice(); + $this->assertStringContainsString( Sunset_Notice::LEARN_MORE_URL, $notice->get_message() ); + } + + /** + * Test the plugin-row notice output. + * + * @return void + */ + public function test_plugin_row_notice_output() { + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + if ( \is_multisite() ) { + \grant_super_admin( $user_id ); + } + \wp_set_current_user( $user_id ); + + $notice = new Sunset_Notice(); + \ob_start(); + $notice->the_plugin_row_notice( \plugin_basename( \PROGRESS_PLANNER_FILE ), [] ); + $output = \ob_get_clean(); + + $this->assertStringContainsString( 'plugin-update-tr', $output ); + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringContainsString( Sunset_Notice::LEARN_MORE_URL, $output ); + } + + /** + * Test that the certificate endpoint is registered on instantiation. + * + * @return void + */ + public function test_certificate_endpoint_registered() { + $notice = new Sunset_Notice(); + $this->assertNotFalse( \has_action( 'admin_post_' . Sunset_Notice::CERTIFICATE_ACTION, [ $notice, 'render_certificate' ] ) ); + } + + /** + * Test that get_completed_badges only returns badges with 100% progress. + * + * @return void + */ + public function test_get_completed_badges_are_complete() { + $notice = new Sunset_Notice(); + $badges = $notice->get_completed_badges(); + $this->assertIsArray( $badges ); + foreach ( $badges as $badge ) { + $this->assertInstanceOf( \Progress_Planner\Badges\Badge::class, $badge ); + $this->assertSame( 100, (int) $badge->get_progress()['progress'] ); + } + } + + /** + * Test the certificate output contains the site URL and activation year. + * + * @return void + */ + public function test_certificate_output() { + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $user_id ); + + $notice = new Sunset_Notice(); + \ob_start(); + $notice->the_certificate(); + $output = \ob_get_clean(); + + $this->assertStringContainsString( \home_url(), $output ); + $this->assertStringContainsString( \progress_planner()->get_activation_date()->format( 'Y' ), $output ); + $this->assertStringContainsString( 'prpl-certificate', $output ); + } + + /** + * Test that the certificate endpoint dies for users without capabilities. + * + * @return void + */ + public function test_certificate_requires_capability() { + \wp_set_current_user( 0 ); + + $notice = new Sunset_Notice(); + $this->expectException( \WPDieException::class ); + $notice->render_certificate(); + } + + /** + * Test that the plugin-row notice outputs nothing for users without capabilities. + * + * @return void + */ + public function test_plugin_row_notice_requires_capability() { + \wp_set_current_user( 0 ); + + $notice = new Sunset_Notice(); + \ob_start(); + $notice->the_plugin_row_notice( \plugin_basename( \PROGRESS_PLANNER_FILE ), [] ); + $output = \ob_get_clean(); + + $this->assertSame( '', $output ); + } +} diff --git a/views/sunset-certificate.php b/views/sunset-certificate.php new file mode 100644 index 000000000..47b17c1ec --- /dev/null +++ b/views/sunset-certificate.php @@ -0,0 +1,216 @@ +get_admin__sunset_notice(); +$prpl_completed_badges = $prpl_sunset_notice->get_completed_badges(); +$prpl_activation_date = \progress_planner()->get_activation_date(); +$prpl_badge_svg_url = \progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/badge-svg/?badge_id='; + +// Scale the badges to the available space, so the certificate always fits on a single A4 page. +$prpl_badge_count = \count( $prpl_completed_badges ); +if ( $prpl_badge_count <= 8 ) { + $prpl_badge_size = 24; // mm. +} elseif ( $prpl_badge_count <= 18 ) { + $prpl_badge_size = 18; +} elseif ( $prpl_badge_count <= 32 ) { + $prpl_badge_size = 14; +} else { + $prpl_badge_size = 10; +} + +?> + +> + + + + <?php \esc_html_e( 'Progress Planner certificate', 'progress-planner' ); ?> + + + +
+ + +

+ +

+ ' . \esc_html( \get_bloginfo( 'name' ) ) . '', + '' . \esc_html( \home_url() ) . '' + ); + ?> +

+ +

+ format( 'U' ) ) ) + ); + ?> +

+ + +
+ +
+ <?php echo \esc_attr( $prpl_badge->get_name() ); ?> + get_name() ); ?> +
+ +
+ + + +
+ + + + diff --git a/views/sunset-notice.php b/views/sunset-notice.php new file mode 100644 index 000000000..dedd4d16f --- /dev/null +++ b/views/sunset-notice.php @@ -0,0 +1,22 @@ + +
+
+

+

get_admin__sunset_notice()->get_message() ); ?>

+

+ + + +
+
From eee87dcde35ae658d9c81b76b7d8769972d49417 Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Tue, 1 Sep 2026 10:13:31 +0200 Subject: [PATCH 2/4] Remove the learn-more link from the sunset notice There will be no blog post to link to, so the notice now only contains the thank-you message (plus the certificate link/button). Co-Authored-By: Claude Fable 5 --- classes/admin/class-sunset-notice.php | 16 +--------------- tests/phpunit/test-class-sunset-notice.php | 9 +++++---- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/classes/admin/class-sunset-notice.php b/classes/admin/class-sunset-notice.php index 4bcde56ee..ae47d373b 100644 --- a/classes/admin/class-sunset-notice.php +++ b/classes/admin/class-sunset-notice.php @@ -16,13 +16,6 @@ */ class Sunset_Notice { - /** - * The URL with more information about the end of support. - * - * @var string - */ - const LEARN_MORE_URL = 'https://prpl.fyi/sunset'; - /** * The admin-post action for the certificate page. * @@ -72,17 +65,10 @@ public function should_show() { /** * Get the notice message, shared by both surfaces. * - * Contains an anchor tag, so it should be escaped with `wp_kses_post` on output. - * * @return string */ public function get_message() { - return \sprintf( - /* translators: %1$s: opening tag, %2$s: closing tag. */ - \esc_html__( 'Support and updates for the free Progress Planner plugin are ending. We are incredibly grateful for your support over the years — thank you for improving your website with us! %1$sLearn more about what this means for your site%2$s.', 'progress-planner' ), - '', - '' - ); + return \esc_html__( 'Support and updates for the free Progress Planner plugin are ending. We are incredibly grateful for your support over the years — thank you for improving your website with us!', 'progress-planner' ); } /** diff --git a/tests/phpunit/test-class-sunset-notice.php b/tests/phpunit/test-class-sunset-notice.php index 0c875330c..bd561065b 100644 --- a/tests/phpunit/test-class-sunset-notice.php +++ b/tests/phpunit/test-class-sunset-notice.php @@ -61,13 +61,14 @@ public function test_hooks_not_registered_when_filtered_out() { } /** - * Test that the message contains the learn-more link. + * Test that the message is not empty and contains no links. * * @return void */ - public function test_message_contains_learn_more_link() { + public function test_message_has_no_links() { $notice = new Sunset_Notice(); - $this->assertStringContainsString( Sunset_Notice::LEARN_MORE_URL, $notice->get_message() ); + $this->assertNotEmpty( $notice->get_message() ); + $this->assertStringNotContainsString( 'get_message() ); } /** @@ -89,7 +90,7 @@ public function test_plugin_row_notice_output() { $this->assertStringContainsString( 'plugin-update-tr', $output ); $this->assertStringContainsString( 'notice-warning', $output ); - $this->assertStringContainsString( Sunset_Notice::LEARN_MORE_URL, $output ); + $this->assertStringContainsString( Sunset_Notice::CERTIFICATE_ACTION, $output ); } /** From ea055c7db76b43158db3665301d062e8d5673982 Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Tue, 1 Sep 2026 11:47:24 +0200 Subject: [PATCH 3/4] Change notice copy from "are ending" to "have ended" Co-Authored-By: Claude Fable 5 --- classes/admin/class-sunset-notice.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/admin/class-sunset-notice.php b/classes/admin/class-sunset-notice.php index ae47d373b..6adb5ee84 100644 --- a/classes/admin/class-sunset-notice.php +++ b/classes/admin/class-sunset-notice.php @@ -68,7 +68,7 @@ public function should_show() { * @return string */ public function get_message() { - return \esc_html__( 'Support and updates for the free Progress Planner plugin are ending. We are incredibly grateful for your support over the years — thank you for improving your website with us!', 'progress-planner' ); + return \esc_html__( 'Support and updates for the free Progress Planner plugin have ended. We are incredibly grateful for your support over the years — thank you for improving your website with us!', 'progress-planner' ); } /** From bec508f2a79cec76e10e8e5761b74323085e40fe Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 4 Sep 2026 12:43:35 +0200 Subject: [PATCH 4/4] Distinguish the sunset row notice from core update notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugins-page row used core's `update-message` class, which injects the red circular-arrows dashicon (\f463) via `.update-message p:before`. That made the sunset notice visually identical to an "update available" row — the opposite of what it means. Drop `update-message` and render a warning dashicon instead. Core keys the row spacing off `.notice`, not `update-message`, so the layout is unchanged. The style is inline because `assets/css/admin.css` is only enqueued on `toplevel_page_progress-planner`, so a rule there would never reach the plugins page. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011jLxPbbCpRuFZFHMBrSoPq --- classes/admin/class-sunset-notice.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/classes/admin/class-sunset-notice.php b/classes/admin/class-sunset-notice.php index 6adb5ee84..e7a6b4bc8 100644 --- a/classes/admin/class-sunset-notice.php +++ b/classes/admin/class-sunset-notice.php @@ -168,11 +168,24 @@ public function the_plugin_row_notice( $plugin_file ) { ?> -
+

get_message() . ' ' . $this->get_certificate_message() ); ?>

+