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