From 19ff397d7cef72a834f53ddd80b556e0f5eb78bf Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Thu, 27 Aug 2026 16:30:54 +0200 Subject: [PATCH] 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() ); ?>

+

+ + + +
+