From be3d1d542d1f6a62b73d5ba7d794c65f579af571 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Wed, 12 Aug 2026 20:44:07 +0200 Subject: [PATCH 1/2] feat(admin): surface OwlStack Cloud from Settings and move it up the menu Cloud registered last on admin_menu, so it sat below all eleven platform pages where nobody would find it. It now passes position: 1 and lands directly after Settings. Adds a short explanation of what Cloud is, on the two screens where someone is already thinking about publishing: - Settings gets a dismissible card contrasting the eleven platforms this plugin publishes to directly against the 31 Cloud reaches, and links to the Cloud page to connect. - The Cloud page shows a trial invitation only when the site is not yet paired, so it disappears once someone has connected. Both live inside the plugin's own screens with no site-wide notices, and the card records dismissal per user. Outbound links carry UTM parameters so signups originating from the plugin can be attributed. The card states that direct publishing stays free, because it does. It is the reason to trust the rest of the message. 55 tests pass (7 new for CloudPromo), PHPCS clean. --- assets/css/admin.css | 27 +++++++ readme.txt | 2 + src/Admin/CloudPromo.php | 95 +++++++++++++++++++++++++ src/Admin/CloudSettingsPage.php | 2 + src/Admin/views/cloud-settings-page.php | 25 +++++++ src/Admin/views/settings-page.php | 29 ++++++++ src/Plugin.php | 3 + tests/Unit/Admin/CloudPromoTest.php | 64 +++++++++++++++++ tests/bootstrap.php | 32 +++++++++ 9 files changed, 279 insertions(+) create mode 100644 src/Admin/CloudPromo.php create mode 100644 tests/Unit/Admin/CloudPromoTest.php diff --git a/assets/css/admin.css b/assets/css/admin.css index 3c55697..c14e39a 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -423,3 +423,30 @@ max-width: 100%; } } + +/* Cloud promo card */ +.owlstack-cloud-promo { + padding: 12px 16px; + margin: 20px 0; + border-left-width: 4px; +} + +.owlstack-cloud-promo__title { + margin: 0 0 8px; + font-size: 15px; + line-height: 1.4; +} + +.owlstack-cloud-promo p { + max-width: 46em; +} + +.owlstack-cloud-promo__dismiss { + margin-left: 12px; + color: #646970; + text-decoration: none; +} + +.owlstack-cloud-promo__dismiss:hover { + color: #135e96; +} diff --git a/readme.txt b/readme.txt index 150ee75..029ce03 100644 --- a/readme.txt +++ b/readme.txt @@ -189,6 +189,8 @@ This plugin connects to external third-party services to publish your content. D * New: Incoming content controls — always draft, always publish, or honor the requested status; configurable post author and post type. * New: REST endpoints under `owlstack/v1/cloud` for site info, post creation, image upload, and removal of Cloud-created posts. Token-authenticated; only a SHA-256 hash of the token is stored. * The plugin makes no outbound calls to OwlStack Cloud; the feature is inactive until a token is generated. +* The Cloud page now sits directly after Settings in the Owlstack menu, instead of below every platform. +* Settings gains a short, dismissible explanation of what OwlStack Cloud is and how it differs from publishing directly from this site. = 1.0.1 = * Improved settings update feedback on the platform settings pages. diff --git a/src/Admin/CloudPromo.php b/src/Admin/CloudPromo.php new file mode 100644 index 0000000..b8742ad --- /dev/null +++ b/src/Admin/CloudPromo.php @@ -0,0 +1,95 @@ + 'wordpress-plugin', + 'utm_medium' => 'plugin-admin', + 'utm_campaign' => 'cloud-connect', + 'utm_content' => $placement, + ], + self::SITE_URL . $path, + ); + } + + /** + * Has the current user dismissed the Settings card? + */ + public static function isDismissed(?int $userId = null): bool + { + $userId ??= get_current_user_id(); + + if ($userId <= 0) { + return false; + } + + return (bool) get_user_meta($userId, self::DISMISS_META, true); + } + + /** + * Nonce-protected dismissal link. + */ + public static function dismissUrl(): string + { + return wp_nonce_url( + admin_url('admin-post.php?action=' . self::DISMISS_ACTION), + self::DISMISS_ACTION, + ); + } + + /** + * Register the dismissal handler. + */ + public static function registerActions(): void + { + add_action('admin_post_' . self::DISMISS_ACTION, [self::class, 'handleDismiss']); + } + + /** + * Persist the dismissal for the current user and return to Settings. + */ + public static function handleDismiss(): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to do that.', 'owlstack')); + } + + check_admin_referer(self::DISMISS_ACTION); + + $userId = get_current_user_id(); + + if ($userId > 0) { + update_user_meta($userId, self::DISMISS_META, '1'); + } + + wp_safe_redirect(admin_url('admin.php?page=owlstack')); + exit; + } +} diff --git a/src/Admin/CloudSettingsPage.php b/src/Admin/CloudSettingsPage.php index 2f2747d..06d9fcd 100644 --- a/src/Admin/CloudSettingsPage.php +++ b/src/Admin/CloudSettingsPage.php @@ -39,6 +39,8 @@ public function register(): void capability: 'manage_options', menu_slug: self::PAGE_SLUG, callback: [$this, 'render'], + // Directly after Settings, ahead of the per-platform pages. + position: 1, ); } diff --git a/src/Admin/views/cloud-settings-page.php b/src/Admin/views/cloud-settings-page.php index f3fb2eb..1aa8e5a 100644 --- a/src/Admin/views/cloud-settings-page.php +++ b/src/Admin/views/cloud-settings-page.php @@ -30,6 +30,31 @@

+ +
+

+ +

+

+ +

+

+ + + + + + +

+
+ +

diff --git a/src/Admin/views/settings-page.php b/src/Admin/views/settings-page.php index 2447e75..e4ba3cc 100644 --- a/src/Admin/views/settings-page.php +++ b/src/Admin/views/settings-page.php @@ -20,6 +20,35 @@ settings_errors('owlstack_settings'); ?> + +
+

+ +

+

+ +

+

+ + + + + + + + + +

+
+ +

diff --git a/src/Plugin.php b/src/Plugin.php index 23b4634..768c4b8 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -30,6 +30,7 @@ use Owlstack\Core\Platforms\Twitter\TwitterPlatform; use Owlstack\Core\Platforms\WhatsApp\WhatsAppPlatform; use Owlstack\Core\Publishing\Publisher; +use Owlstack\WordPress\Admin\CloudPromo; use Owlstack\WordPress\Admin\CloudSettingsPage; use Owlstack\WordPress\Admin\DeliveryLogsPage; use Owlstack\WordPress\Admin\MetaBox; @@ -333,6 +334,8 @@ private function registerAdminHooks(): void add_action('admin_menu', [$cloudPage, 'register']); $cloudPage->registerActions(); + CloudPromo::registerActions(); + add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']); } diff --git a/tests/Unit/Admin/CloudPromoTest.php b/tests/Unit/Admin/CloudPromoTest.php new file mode 100644 index 0000000..b56f7fb --- /dev/null +++ b/tests/Unit/Admin/CloudPromoTest.php @@ -0,0 +1,64 @@ +assertStringStartsWith(CloudPromo::SITE_URL . '/register', CloudPromo::url('/register')); + } + + public function testUrlCarriesAttributionParameters(): void + { + $url = CloudPromo::url('/', 'settings-card'); + + $this->assertStringContainsString('utm_source=wordpress-plugin', $url); + $this->assertStringContainsString('utm_medium=plugin-admin', $url); + $this->assertStringContainsString('utm_campaign=cloud-connect', $url); + $this->assertStringContainsString('utm_content=settings-card', $url); + } + + public function testPlacementDistinguishesCallSites(): void + { + $this->assertStringContainsString( + 'utm_content=cloud-page', + CloudPromo::url('/register', 'cloud-page'), + ); + } + + public function testNotDismissedForLoggedOutUser(): void + { + $this->assertFalse(CloudPromo::isDismissed(0)); + } + + public function testNotDismissedByDefault(): void + { + $this->assertFalse(CloudPromo::isDismissed(7)); + } + + public function testDismissalIsPerUser(): void + { + update_user_meta(7, 'owlstack_cloud_promo_dismissed', '1'); + + $this->assertTrue(CloudPromo::isDismissed(7)); + $this->assertFalse(CloudPromo::isDismissed(8)); + } + + public function testCloudPlatformCountExceedsThePluginsOwn(): void + { + $this->assertGreaterThan(11, CloudPromo::CLOUD_PLATFORM_COUNT); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3f44d13..1ec4c90 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -169,3 +169,35 @@ function wp_json_encode(mixed $data, int $options = 0, int $depth = 512): string } require_once __DIR__ . '/stubs-rest.php'; + +if (! function_exists('add_query_arg')) { + function add_query_arg(array $args, string $url): string + { + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . http_build_query($args); + } +} + +if (! function_exists('get_current_user_id')) { + function get_current_user_id(): int + { + return $GLOBALS['owlstack_test_current_user'] ?? 0; + } +} + +if (! function_exists('get_user_meta')) { + function get_user_meta(int $userId, string $key, bool $single = false) + { + return $GLOBALS['owlstack_test_user_meta'][$userId][$key] ?? ''; + } +} + +if (! function_exists('update_user_meta')) { + function update_user_meta(int $userId, string $key, $value): bool + { + $GLOBALS['owlstack_test_user_meta'][$userId][$key] = $value; + + return true; + } +} From 78cf15b52fdfc94679dc04cb495fdda833725de4 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Wed, 12 Aug 2026 20:44:39 +0200 Subject: [PATCH 2/2] fix(build): keep vendor git hooks out of the distributed package --- .distignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.distignore b/.distignore index 93ac448..f5d67f1 100644 --- a/.distignore +++ b/.distignore @@ -8,6 +8,7 @@ node_modules wp-assets dist .svn-wp +.githooks # Development & CI files .gitignore