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 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; + } +}