Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ node_modules
wp-assets
dist
.svn-wp
.githooks

# Development & CI files
.gitignore
Expand Down
27 changes: 27 additions & 0 deletions assets/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions src/Admin/CloudPromo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace Owlstack\WordPress\Admin;

defined( 'ABSPATH' ) || exit;

/**
* Explains OwlStack Cloud on the plugin's own admin screens.
*
* Stays inside the plugin's pages: no site-wide notices, and the Settings
* card is dismissible per user.
*/
class CloudPromo
{
public const SITE_URL = 'https://owlstack.app';

/** Platforms reachable through Cloud, against the 11 this plugin publishes to directly. */
public const CLOUD_PLATFORM_COUNT = 31;

private const DISMISS_META = 'owlstack_cloud_promo_dismissed';

private const DISMISS_ACTION = 'owlstack_dismiss_cloud_promo';

/**
* Build a tagged link to the marketing site so signups can be attributed.
*/
public static function url(string $path = '/', string $placement = 'settings-card'): string
{
return add_query_arg(
[
'utm_source' => '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;
}
}
2 changes: 2 additions & 0 deletions src/Admin/CloudSettingsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
25 changes: 25 additions & 0 deletions src/Admin/views/cloud-settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@
<?php esc_html_e('Connect this site to OwlStack Cloud to receive posts you compose there. Generate a site token below, then paste it together with your site URL into the OwlStack dashboard when connecting WordPress. The token only allows OwlStack Cloud to create posts, upload images, and remove posts it created — nothing else.', 'owlstack'); ?>
</p>

<?php if (! $isPaired): ?>
<div class="owlstack-cloud-promo notice notice-info">
<h2 class="owlstack-cloud-promo__title">
<?php esc_html_e('Do not have an OwlStack account yet?', 'owlstack'); ?>
</h2>
<p>
<?php
printf(
/* translators: %d: number of platforms OwlStack Cloud supports */
esc_html__('OwlStack Cloud is the hosted dashboard this token pairs with. It publishes to %d platforms, schedules ahead on a shared calendar, reports on what performed, and drafts posts for you with AI. WordPress becomes one destination among them, so a single post can go to this site and your social accounts at once.', 'owlstack'),
(int) \Owlstack\WordPress\Admin\CloudPromo::CLOUD_PLATFORM_COUNT,
);
?>
</p>
<p>
<a class="button button-primary" href="<?php echo esc_url(\Owlstack\WordPress\Admin\CloudPromo::url('/register', 'cloud-page')); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e('Start a free trial', 'owlstack'); ?>
</a>
<a class="button" href="<?php echo esc_url(\Owlstack\WordPress\Admin\CloudPromo::url('/platforms', 'cloud-page-platforms')); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e('See all platforms', 'owlstack'); ?>
</a>
</p>
</div>
<?php endif; ?>

<!-- Site token -->
<h2><?php esc_html_e('Site Token', 'owlstack'); ?></h2>

Expand Down
29 changes: 29 additions & 0 deletions src/Admin/views/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@
settings_errors('owlstack_settings');
?>

<?php if (! \Owlstack\WordPress\Admin\CloudPromo::isDismissed()): ?>
<div class="owlstack-cloud-promo notice notice-info">
<h2 class="owlstack-cloud-promo__title">
<?php esc_html_e('Publish beyond this site with OwlStack Cloud', 'owlstack'); ?>
</h2>
<p>
<?php
printf(
/* translators: 1: platforms this plugin supports, 2: platforms OwlStack Cloud supports */
esc_html__('This plugin publishes to %1$d platforms straight from WordPress using your own credentials, and it always will. OwlStack Cloud reaches %2$d platforms and adds scheduling, a content calendar, analytics, and AI drafting, with this site as one of its destinations.', 'owlstack'),
count($platforms),
(int) \Owlstack\WordPress\Admin\CloudPromo::CLOUD_PLATFORM_COUNT,
);
?>
</p>
<p>
<a class="button button-primary" href="<?php echo esc_url(admin_url('admin.php?page=owlstack-cloud')); ?>">
<?php esc_html_e('Connect this site to Cloud', 'owlstack'); ?>
</a>
<a class="button" href="<?php echo esc_url(\Owlstack\WordPress\Admin\CloudPromo::url('/', 'settings-card')); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e('Start a free trial', 'owlstack'); ?>
</a>
<a class="owlstack-cloud-promo__dismiss" href="<?php echo esc_url(\Owlstack\WordPress\Admin\CloudPromo::dismissUrl()); ?>">
<?php esc_html_e('Dismiss', 'owlstack'); ?>
</a>
</p>
</div>
<?php endif; ?>

<!-- Platform overview -->
<h2><?php esc_html_e('Platforms', 'owlstack'); ?></h2>
<p><?php esc_html_e('Configure each platform from its own settings page. Platforms with valid credentials are marked as connected.', 'owlstack'); ?></p>
Expand Down
3 changes: 3 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}

Expand Down
64 changes: 64 additions & 0 deletions tests/Unit/Admin/CloudPromoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Owlstack\WordPress\Tests\Unit\Admin;

use Owlstack\WordPress\Admin\CloudPromo;
use Owlstack\WordPress\Tests\TestCase;

class CloudPromoTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$GLOBALS['owlstack_test_user_meta'] = [];
$GLOBALS['owlstack_test_current_user'] = 0;
}

public function testUrlPointsAtTheMarketingSite(): void
{
$this->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);
}
}
32 changes: 32 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading