From b85cae2aa7f2fc0ec13c16e0d23c8f68e4e0a547 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Mon, 28 Sep 2026 14:56:40 +0200 Subject: [PATCH 1/3] Read branding and badges from the new static branding API Branding moves off progressplanner.com to a static API built from the ProgressPlanner/yolo-branding repo. The plugin now fetches one resolved document per brand (/v1/brands/{id}.json) from a new PROGRESS_PLANNER_BRANDING_SERVER_URL (default https://branding.yolo.online); PROGRESS_PLANNER_REMOTE_SERVER_ROOT_URL keeps serving lessons, challenges and onboarding. - Logo and menu icon are URLs in the document: no more /wp/v2/media lookups. - Badges come from the document's badge map; unbranded sites read the defaults (0.json) for badges only and keep the built-in text defaults. An unknown badge shows the placeholder (the old endpoint returned the Nov 2024 badge). - Branding cache drops from a week to a day. Co-Authored-By: Claude Opus 5.5 --- assets/js/web-components/prpl-badge.js | 11 +- classes/admin/class-enqueue.php | 10 +- classes/class-base.php | 16 +++ classes/ui/class-branding.php | 150 ++++++++++++------------- views/popovers/parts/upgrade-tasks.php | 2 +- views/sunset-certificate.php | 5 +- 6 files changed, 100 insertions(+), 94 deletions(-) diff --git a/assets/js/web-components/prpl-badge.js b/assets/js/web-components/prpl-badge.js index 5fd3b58a8..7033f5099 100644 --- a/assets/js/web-components/prpl-badge.js +++ b/assets/js/web-components/prpl-badge.js @@ -13,18 +13,17 @@ customElements.define( 'prpl-badge', class extends HTMLElement { - constructor( badgeId, badgeName, brandingId = 0 ) { + constructor( badgeId, badgeName ) { // Get parent class properties super(); badgeId = badgeId || this.getAttribute( 'badge-id' ); badgeName = badgeName || this.getAttribute( 'badge-name' ); - brandingId = brandingId || this.getAttribute( 'branding-id' ); - let url = `${ progressPlannerBadge.remoteServerRootUrl }/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=${ badgeId }`; - if ( brandingId ) { - url += `&branding_id=${ brandingId }`; - } + // Badge URLs come from the site's brand; a badge the brand does not have shows the placeholder. + const url = + progressPlannerBadge.badgeUrls[ badgeId ] || + progressPlannerBadge.placeholderImageUrl; if ( ! badgeName || 'null' === badgeName ) { badgeName = `${ prplL10n( 'badge' ) }`; diff --git a/classes/admin/class-enqueue.php b/classes/admin/class-enqueue.php index 20c045c4e..01874b023 100644 --- a/classes/admin/class-enqueue.php +++ b/classes/admin/class-enqueue.php @@ -200,7 +200,7 @@ public function localize_script( $handle, $localize_data = [] ) { $localize_data = [ 'name' => 'progressPlannerBadge', 'data' => [ - 'remoteServerRootUrl' => \progress_planner()->get_remote_server_root_url(), + 'badgeUrls' => (object) \progress_planner()->get_ui__branding()->get_badge_urls(), 'placeholderImageUrl' => \progress_planner()->get_placeholder_svg(), ], ]; @@ -325,11 +325,13 @@ public function localize_script( $handle, $localize_data = [] ) { * @return string[] The badge URLs. */ private function get_badge_urls() { + $url = [ \progress_planner()->get_ui__branding(), 'get_badge_url' ]; + // Get the monthly badge URL. $monthly_badge = \progress_planner()->get_badges()->get_badge( Monthly::get_badge_id_from_date( new \DateTime() ) ); if ( $monthly_badge ) { - $badge_urls['month'] = \progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=' . $monthly_badge->get_id() . '&branding_id=' . (int) \progress_planner()->get_ui__branding()->get_branding_id(); + $badge_urls['month'] = $url( $monthly_badge->get_id() ); } // Get the content and maintenance badge URLs. @@ -338,12 +340,12 @@ private function get_badge_urls() { foreach ( $set_badges as $badge ) { $progress = $badge->get_progress(); if ( $progress['progress'] > 100 ) { - $badge_urls[ $context ] = \progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=' . $badge->get_id() . '&branding_id=' . (int) \progress_planner()->get_ui__branding()->get_branding_id(); + $badge_urls[ $context ] = $url( $badge->get_id() ); } } if ( ! isset( $badge_urls[ $context ] ) ) { // Fallback to the first badge in the set if no badge is completed. - $badge_urls[ $context ] = \progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=' . $set_badges[0]->get_id() . '&branding_id=' . (int) \progress_planner()->get_ui__branding()->get_branding_id(); + $badge_urls[ $context ] = $url( $set_badges[0]->get_id() ); } } diff --git a/classes/class-base.php b/classes/class-base.php index 1c0c15867..d61f72bdf 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -282,6 +282,22 @@ public function get_remote_server_root_url() { : 'https://progressplanner.com'; } + /** + * Get the branding server root URL. + * + * Branding (brand data and badge images) is served separately from the + * remote server above, which keeps serving lessons, challenges and onboarding. + * + * @return string + */ + public function get_branding_server_url() { + return \untrailingslashit( + \defined( 'PROGRESS_PLANNER_BRANDING_SERVER_URL' ) + ? \constant( 'PROGRESS_PLANNER_BRANDING_SERVER_URL' ) + : 'https://branding.yolo.online' + ); + } + /** * Get the placeholder SVG. * diff --git a/classes/ui/class-branding.php b/classes/ui/class-branding.php index 0732a7db5..eb7e406ae 100644 --- a/classes/ui/class-branding.php +++ b/classes/ui/class-branding.php @@ -79,7 +79,10 @@ public function get_branding_id(): int { } /** - * Get the api data. + * Get the branding data for the current branding ID. + * + * Sites without a branding ID (0) get an empty array and use the defaults + * built into the plugin; only their badges come from the branding server. * * @return array */ @@ -88,14 +91,56 @@ public function get_api_data(): array { return []; } + return $this->get_brand( $this->get_branding_id() ); + } + + /** + * Get a brand document from the branding server. + * + * The server publishes one resolved JSON document per brand + * (`/v1/brands/{id}.json`, `0.json` for the defaults): every field and asset + * already falls back to the defaults, and asset URLs carry a content hash. + * + * @param int $branding_id The branding ID, 0 for the defaults. + * + * @return array The brand document, or an empty array if it is unavailable. + */ + public function get_brand( int $branding_id ): array { $response = $this->get_remote_data( - \progress_planner()->get_remote_server_root_url() . '/wp-json/wp/v2/pp-branding/' . $this->get_branding_id() + \progress_planner()->get_branding_server_url() . '/v1/brands/' . $branding_id . '.json' ); - if ( ! $response ) { + if ( ! $response || ! \is_string( $response ) ) { return []; } - return \is_array( $response ) ? $response : \json_decode( $response, true ); + $brand = \json_decode( $response, true ); + + // Only format 1 is understood; anything else degrades to the plugin defaults. + return \is_array( $brand ) && 1 === ( $brand['format'] ?? null ) ? $brand : []; + } + + /** + * Get the image URL of a badge. + * + * @param string $badge_id The badge ID. + * + * @return string The badge URL, or the placeholder image if the brand has no such badge. + */ + public function get_badge_url( string $badge_id ): string { + return $this->get_badge_urls()[ $badge_id ] ?? \progress_planner()->get_placeholder_svg(); + } + + /** + * Get the image URLs of all badges, keyed by badge ID. + * + * Unbranded sites read the badges from the defaults document (`0.json`). + * + * @return array + */ + public function get_badge_urls(): array { + $brand = 0 === $this->get_branding_id() ? $this->get_brand( 0 ) : $this->get_api_data(); + + return isset( $brand['badges'] ) && \is_array( $brand['badges'] ) ? $brand['badges'] : []; } /** @@ -104,21 +149,10 @@ public function get_api_data(): array { * @return void */ public function the_logo(): void { - // Get the logo ID from the API data. - if ( ! empty( $this->get_api_data() ) - && isset( $this->get_api_data()['logo'] ) - && ! empty( $this->get_api_data()['logo'] ) - ) { - $logo_id = $this->get_api_data()['logo']; - // Get the logo URL. - $response = $this->get_remote_data( \progress_planner()->get_remote_server_root_url() . '/wp-json/wp/v2/media/' . $logo_id ); - if ( $response ) { - $media = \json_decode( $response, true ); - if ( isset( $media['source_url'] ) ) { - echo 'Logo'; - return; - } - } + $logo_url = $this->get_api_data()['logo'] ?? ''; + if ( $logo_url ) { + echo 'Logo'; + return; } \progress_planner()->the_asset( 'images/logo_progress_planner.svg' ); @@ -130,9 +164,7 @@ public function the_logo(): void { * @return string */ public function get_custom_css(): string { - return empty( $this->get_api_data() ) || empty( $this->get_api_data()['custom_css'] ) - ? '' - : $this->get_api_data()['custom_css']; + return (string) ( $this->get_api_data()['css'] ?? '' ); } /** @@ -167,25 +199,16 @@ public function enqueue_inline_css( string $handle ): void { * @return string SVG markup or base64-encoded data URI. */ public function get_admin_menu_icon( bool $raw = false ): string { - $admin_menu_icon_id = empty( $this->get_api_data() ) || empty( $this->get_api_data()['admin_menu_icon'] ) - ? '' - : $this->get_api_data()['admin_menu_icon']; + $admin_menu_icon_url = $this->get_api_data()['admin_menu']['icon'] ?? ''; // Default SVG with brand colors (purple #38296d and orange #faa310). $svg = ''; - if ( $admin_menu_icon_id ) { - // Get the icon URL from the API. - $response = $this->get_remote_data( \progress_planner()->get_remote_server_root_url() . '/wp-json/wp/v2/media/' . $admin_menu_icon_id ); - if ( $response ) { - $media = \json_decode( $response, true ); - if ( \is_array( $media ) && \array_key_exists( 'source_url', $media ) ) { - // Get the content of the SVG. - $content = $this->get_remote_data( $media['source_url'] ); - if ( $content ) { - $svg = $content; - } - } + if ( $admin_menu_icon_url ) { + // Inline the SVG: the URL carries a content hash, so a changed icon is a new cache entry. + $content = $this->get_remote_data( $admin_menu_icon_url ); + if ( $content && \is_string( $content ) ) { + $svg = $content; } } @@ -203,9 +226,7 @@ public function get_admin_menu_icon( bool $raw = false ): string { * @return string */ public function get_admin_menu_name(): string { - return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['admin_menu_name'] ) - ? 'Progress Planner' - : $this->get_api_data()['admin_menu_name']; + return (string) ( $this->get_api_data()['admin_menu']['name'] ?? 'Progress Planner' ); } /** @@ -214,9 +235,7 @@ public function get_admin_menu_name(): string { * @return string */ public function get_admin_submenu_name(): string { - return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['admin_submenu_name'] ) - ? $this->get_admin_menu_name() - : $this->get_api_data()['admin_submenu_name']; + return (string) ( $this->get_api_data()['admin_menu']['submenu'] ?? $this->get_admin_menu_name() ); } /** @@ -238,9 +257,7 @@ public function get_admin_submenu_position() { * @return string */ public function get_ravi_name(): string { - return empty( $this->get_api_data() ) || empty( $this->get_api_data()['ravis_name'] ) - ? 'Ravi' - : $this->get_api_data()['ravis_name']; + return (string) ( $this->get_api_data()['assistant_name'] ?? 'Ravi' ); } /** @@ -269,7 +286,7 @@ public function get_remote_data( $url ) { $body = \wp_remote_retrieve_body( $response ); - \progress_planner()->get_utils__cache()->set( $cache_key, $body, WEEK_IN_SECONDS ); + \progress_planner()->get_utils__cache()->set( $cache_key, $body, DAY_IN_SECONDS ); return $body; } @@ -282,11 +299,7 @@ public function get_remote_data( $url ) { * @return array<\Progress_Planner\Admin\Widgets\Widget> */ public function filter_widgets( $widgets ) { - if ( empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['papers'] ) ) { - return $widgets; - } - - $show_papers = $this->get_api_data()['papers']; + $show_papers = $this->get_api_data()['widgets'] ?? []; if ( ! $show_papers ) { return $widgets; } @@ -303,17 +316,7 @@ public function filter_widgets( $widgets ) { * @return string */ public function get_widget_title( $widget_id, $default_value = '' ) { - if ( empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['widget_titles'] ) ) { - return $default_value; - } - - foreach ( $this->get_api_data()['widget_titles'] as $widget ) { - if ( $widget['widget_id'] === $widget_id && isset( $widget['title'] ) ) { - return $widget['title']; - } - } - - return $default_value; + return (string) ( $this->get_api_data()['widget_titles'][ $widget_id ] ?? $default_value ); } /** @@ -322,9 +325,7 @@ public function get_widget_title( $widget_id, $default_value = '' ) { * @return string */ public function get_blog_feed_url(): string { - return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['blog_feed_url'] ) - ? \progress_planner()->get_remote_server_root_url() - : $this->get_api_data()['blog_feed_url']; + return (string) ( $this->get_api_data()['blog_feed_url'] ?? \progress_planner()->get_remote_server_root_url() ); } /** @@ -335,18 +336,7 @@ public function get_blog_feed_url(): string { * @return string */ public function get_url( $default_url = '' ) { - $api_data = $this->get_api_data(); - if ( empty( $api_data ) || ! isset( $api_data['links'] ) ) { - return $default_url; - } - - foreach ( $api_data['links'] as $link ) { - if ( $link['original_url'] === $default_url ) { - return $link['url']; - } - } - - return $default_url; + return (string) ( $this->get_api_data()['links'][ $default_url ] ?? $default_url ); } /** @@ -355,8 +345,6 @@ public function get_url( $default_url = '' ) { * @return string */ public function get_seo_plugin_recommendation_slug(): string { - return empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['seo_plugin_recommendation_slug'] ) - ? 'wordpress-seo' - : $this->get_api_data()['seo_plugin_recommendation_slug']; + return (string) ( $this->get_api_data()['recommended_plugins']['seo'] ?? 'wordpress-seo' ); } } diff --git a/views/popovers/parts/upgrade-tasks.php b/views/popovers/parts/upgrade-tasks.php index 898f47d06..1d00d1dbe 100644 --- a/views/popovers/parts/upgrade-tasks.php +++ b/views/popovers/parts/upgrade-tasks.php @@ -101,7 +101,7 @@ <?php \esc_attr_e( 'Badge', 'progress-planner' ); ?> diff --git a/views/sunset-certificate.php b/views/sunset-certificate.php index 47b17c1ec..d019c2241 100644 --- a/views/sunset-certificate.php +++ b/views/sunset-certificate.php @@ -15,7 +15,8 @@ $prpl_sunset_notice = \progress_planner()->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='; +// The certificate always shows the default (unbranded) badges. +$prpl_badge_urls = \progress_planner()->get_ui__branding()->get_brand( 0 )['badges'] ?? []; // Scale the badges to the available space, so the certificate always fits on a single A4 page. $prpl_badge_count = \count( $prpl_completed_badges ); @@ -194,7 +195,7 @@
<?php echo \esc_attr( $prpl_badge->get_name() ); ?> From 2676aad2b434ee1170b78106d231317da84629d6 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Mon, 28 Sep 2026 22:01:47 +0200 Subject: [PATCH 2/3] Apply suggestion from @jdevalk --- classes/class-base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/class-base.php b/classes/class-base.php index d61f72bdf..bae7fe1f3 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -294,7 +294,7 @@ public function get_branding_server_url() { return \untrailingslashit( \defined( 'PROGRESS_PLANNER_BRANDING_SERVER_URL' ) ? \constant( 'PROGRESS_PLANNER_BRANDING_SERVER_URL' ) - : 'https://branding.yolo.online' + : 'https://branding.your.online' ); } From 9280b9452689a19d8511326e51e48d4273f6470a Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Mon, 28 Sep 2026 22:19:59 +0200 Subject: [PATCH 3/3] Honor cached branding failures during badge lookups --- classes/ui/class-branding.php | 3 +- tests/phpunit/test-class-branding-cache.php | 98 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/test-class-branding-cache.php diff --git a/classes/ui/class-branding.php b/classes/ui/class-branding.php index eb7e406ae..278f15d7a 100644 --- a/classes/ui/class-branding.php +++ b/classes/ui/class-branding.php @@ -269,7 +269,8 @@ public function get_ravi_name(): string { public function get_remote_data( $url ) { $cache_key = \md5( $url ); $cached = \progress_planner()->get_utils__cache()->get( $cache_key ); - if ( $cached ) { + // An empty array is a cached failure; only false means a cache miss. + if ( false !== $cached ) { return $cached; } diff --git a/tests/phpunit/test-class-branding-cache.php b/tests/phpunit/test-class-branding-cache.php new file mode 100644 index 000000000..91d521ea6 --- /dev/null +++ b/tests/phpunit/test-class-branding-cache.php @@ -0,0 +1,98 @@ +get_ui__branding(); + $url = \progress_planner()->get_branding_server_url() . '/v1/brands/' . $branding->get_branding_id() . '.json'; + $transient = \Progress_Planner\Utils\Cache::CACHE_PREFIX . \md5( $url ); + $requests = 0; + $recover = false; + $badge_url = 'https://example.org/monthly-2026-m9.svg'; + $mock_response = static function ( $preempt, $args, $request_url ) use ( $url, $failure_type, $badge_url, &$requests, &$recover ) { + if ( $url !== $request_url ) { + return $preempt; + } + + ++$requests; + if ( $recover ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => \wp_json_encode( + [ + 'format' => 1, + 'badges' => [ 'monthly-2026-m9' => $badge_url ], + ] + ), + ]; + } + + return 'transport' === $failure_type + ? new \WP_Error( 'http_request_failed', 'Connection timed out.' ) + : [ + 'response' => [ 'code' => 503 ], + 'body' => 'Service unavailable', + ]; + }; + + \delete_transient( $transient ); + \add_filter( 'pre_http_request', $mock_response, 10, 3 ); + + try { + $this->assertSame( [], $branding->get_badge_urls() ); + foreach ( [ 'monthly-2026-m9', 'awesome-author', 'maintenance-maniac' ] as $badge_id ) { + $this->assertSame( \progress_planner()->get_placeholder_svg(), $branding->get_badge_url( $badge_id ) ); + } + $this->assertSame( 1, $requests, 'Badge localization must reuse the cached failure.' ); + + // A fresh branding instance must also honor the persistent failure cache. + $branding = new \Progress_Planner\UI\Branding(); + $this->assertSame( [], $branding->get_badge_urls() ); + $this->assertSame( 1, $requests ); + + $timeout = (int) \get_option( '_transient_timeout_' . $transient ); + $this->assertGreaterThan( \time(), $timeout ); + $this->assertLessThanOrEqual( \time() + 5 * MINUTE_IN_SECONDS, $timeout ); + + // Expire the transient without sleeping, then allow the server to recover. + \update_option( '_transient_timeout_' . $transient, \time() - 1 ); + $recover = true; + $this->assertSame( $badge_url, $branding->get_badge_url( 'monthly-2026-m9' ) ); + $this->assertSame( $badge_url, $branding->get_badge_url( 'monthly-2026-m9' ) ); + $this->assertSame( 2, $requests, 'Fetch once after expiry and cache the successful response.' ); + } finally { + \remove_filter( 'pre_http_request', $mock_response, 10 ); + \delete_transient( $transient ); + } + } + + /** + * Failure modes handled by the branding client. + * + * @return array> + */ + public static function failure_types() { + return [ + 'transport error' => [ 'transport' ], + 'HTTP error' => [ 'http' ], + ]; + } +}