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;
+}
+
+?>
+
+>
+
+
+
+
+
+
+
+
+
+ the_asset( 'images/logo_progress_planner.svg' ); ?>
+
+
+
+
+
+ ' . \esc_html( \get_bloginfo( 'name' ) ) . '',
+ '' . \esc_html( \home_url() ) . ''
+ );
+ ?>
+
+
+
+ format( 'U' ) ) )
+ );
+ ?>
+
+
+
+
+
+
+
 ); ?>)
+
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 @@
+
+