diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f786ec37cef..57a67db6d46a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3697,6 +3697,9 @@ importers: '@wordpress/dependency-extraction-webpack-plugin': specifier: 6.44.0 version: 6.44.0(webpack@5.105.2) + '@wordpress/editor': + specifier: 14.44.0 + version: 14.44.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/element': specifier: 6.44.0 version: 6.44.0 @@ -3718,6 +3721,9 @@ importers: '@wordpress/notices': specifier: 5.44.0 version: 5.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/plugins': + specifier: 7.44.0 + version: 7.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/primitives': specifier: 4.44.0 version: 4.44.0(react@18.3.1) diff --git a/projects/packages/podcast/changelog/add-post-publish-podcast-promo b/projects/packages/podcast/changelog/add-post-publish-podcast-promo new file mode 100644 index 000000000000..0c8a46943620 --- /dev/null +++ b/projects/packages/podcast/changelog/add-post-publish-podcast-promo @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Posts to Podcast: Add an editor modal inviting eligible sites to create a podcast episode after publishing a post. diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json index 17da66649068..6cd5fa5533a8 100644 --- a/projects/packages/podcast/package.json +++ b/projects/packages/podcast/package.json @@ -50,6 +50,7 @@ "@wordpress/dataviews": "14.1.0", "@wordpress/date": "5.44.0", "@wordpress/dependency-extraction-webpack-plugin": "6.44.0", + "@wordpress/editor": "14.44.0", "@wordpress/element": "6.44.0", "@wordpress/hooks": "4.44.0", "@wordpress/html-entities": "4.44.0", @@ -57,6 +58,7 @@ "@wordpress/icons": "12.2.0", "@wordpress/media-utils": "5.44.0", "@wordpress/notices": "5.44.0", + "@wordpress/plugins": "7.44.0", "@wordpress/primitives": "4.44.0", "@wordpress/route": "0.10.0", "@wordpress/ui": "0.11.0", diff --git a/projects/packages/podcast/src/class-create-ai-podcast-page.php b/projects/packages/podcast/src/class-create-ai-podcast-page.php index 006567734d50..3d2e4eb9c220 100644 --- a/projects/packages/podcast/src/class-create-ai-podcast-page.php +++ b/projects/packages/podcast/src/class-create-ai-podcast-page.php @@ -17,6 +17,8 @@ require_once __DIR__ . '/admin-pages/create-ai-podcast/presets.php'; +use Automattic\Jetpack\Assets; +use Automattic\Jetpack\Status\Host; use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\length_presets; use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\voice_presets; use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\window_presets; @@ -31,6 +33,11 @@ class Create_AI_Podcast_Page { const STYLE_HANDLE = 'jetpack-create-ai-podcast'; const EPISODES_PER_PAGE = 5; + const POST_PUBLISH_PROMO_SCRIPT_HANDLE = 'jetpack-post-publish-podcast-promo'; + const POST_PUBLISH_PROMO_DISMISSED_OPTION = 'jetpack_posts_to_podcast_post_publish_promo_dismissed'; + const POST_PUBLISH_PROMO_MIN_POSTS = 5; + const POST_PUBLISH_PROMO_MIN_VISITORS = 50; + /** * Whether `init()` has wired its hooks. * @@ -48,6 +55,7 @@ public static function init() { self::$initialized = true; add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) ); + add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_post_publish_promo_assets' ) ); } /** @@ -118,6 +126,216 @@ public static function enqueue_assets() { ); } + /** + * Enqueue the post-publish modal in the post block editor for eligible sites. + */ + public static function enqueue_post_publish_promo_assets() { + if ( + ! self::is_post_block_editor() + || self::is_current_post_published_for_post_publish_promo() + || ! self::is_post_publish_promo_site_eligible() + ) { + return; + } + + Assets::register_script( + self::POST_PUBLISH_PROMO_SCRIPT_HANDLE, + '../dist/blocks/post-publish-podcast-promo/editor.js', + __FILE__, + array( + 'enqueue' => true, + 'in_footer' => true, + 'textdomain' => 'jetpack-podcast', + ) + ); + + wp_add_inline_script( + self::POST_PUBLISH_PROMO_SCRIPT_HANDLE, + 'window.jetpackPostPublishPodcastPromo = ' . wp_json_encode( + array( + 'createUrl' => admin_url( 'upload.php?page=' . self::PAGE_SLUG ), + 'dismissPath' => Posts_To_Podcast_Endpoint::get_post_publish_promo_dismiss_rest_path(), + ), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT + ) . ';', + 'before' + ); + } + + /** + * Whether the current editor post has already been published. + */ + private static function is_current_post_published_for_post_publish_promo(): bool { + $post = get_post(); + + return $post instanceof \WP_Post + && 'post' === $post->post_type + && 'publish' === $post->post_status; + } + + /** + * Whether the current screen is the post block editor. + */ + private static function is_post_block_editor(): bool { + if ( ! function_exists( 'get_current_screen' ) ) { + return false; + } + + $screen = get_current_screen(); + return ! empty( $screen ) + && 'post' === $screen->base + && 'post' === $screen->post_type + && $screen->is_block_editor(); + } + + /** + * Whether the site has enough published posts to generate a better episode. + */ + private static function has_enough_recent_posts_for_post_publish_promo(): bool { + /** + * Filters the minimum posts published in the last month needed for the Posts to Podcast post-publish promo. + * + * @since $$next-version$$ + * + * @param int $minimum Minimum number of published posts. + */ + $minimum = (int) apply_filters( + 'jetpack_posts_to_podcast_post_publish_promo_min_published_posts', + self::POST_PUBLISH_PROMO_MIN_POSTS + ); + $minimum = max( 1, $minimum ); + + $published_posts = get_posts( + array( + 'fields' => 'ids', + 'no_found_rows' => true, + 'post_status' => 'publish', + 'post_type' => 'post', + 'posts_per_page' => $minimum, + 'suppress_filters' => false, + 'date_query' => array( + array( + 'after' => '1 month ago', + 'inclusive' => true, + ), + ), + ) + ); + $total = count( $published_posts ); + + $post = get_post(); + if ( $post && 'post' === $post->post_type && 'publish' !== $post->post_status ) { + ++$total; + } + + return $total >= $minimum; + } + + /** + * Whether the site has visitors who could benefit from a podcast episode. + */ + private static function has_visitors_for_post_publish_promo(): bool { + $visitors = self::get_post_publish_promo_visitor_count(); + + /** + * Filters the minimum visitors in the last week needed for the Posts to Podcast post-publish promo. + * + * @since $$next-version$$ + * + * @param int $minimum Minimum number of visitors. + */ + $minimum = (int) apply_filters( + 'jetpack_posts_to_podcast_post_publish_promo_min_visitors', + self::POST_PUBLISH_PROMO_MIN_VISITORS + ); + + return $visitors >= max( 1, $minimum ); + } + + /** + * Fetch the last week's visitor count from Jetpack Stats when available. + */ + private static function get_post_publish_promo_visitor_count(): int { + $host = new Host(); + if ( $host->is_wpcom_simple() ) { + return self::get_wpcom_simple_post_publish_promo_visitor_count(); + } + + if ( class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) ) { + $wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats(); + $stats = $wpcom_stats->get_visits( + array( + 'unit' => 'day', + 'quantity' => 7, + 'stat_fields' => 'visitors', + ) + ); + + if ( ! is_wp_error( $stats ) && is_array( $stats ) ) { + return self::sum_visits_field( $stats, 'visitors' ); + } + } + + return 0; + } + + /** + * Fetch the last week's visitor count directly on WordPress.com Simple. + */ + private static function get_wpcom_simple_post_publish_promo_visitor_count(): int { + if ( ! function_exists( 'stats_get_visitors' ) ) { + return 0; + } + + $visitors = stats_get_visitors( get_current_blog_id(), gmdate( 'Y-m-d' ), 7, 1 ); + + return is_array( $visitors ) ? (int) array_sum( $visitors ) : 0; + } + + /** + * Sum a metric from the Stats visits response. + * + * @param array $stats Stats visits response. + * @param string $field Field to sum. + * @return int + */ + private static function sum_visits_field( array $stats, string $field ): int { + if ( ! isset( $stats['data'] ) || ! is_array( $stats['data'] ) ) { + return 0; + } + + $fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array(); + $index = array_search( $field, $fields, true ); + if ( false === $index ) { + return 0; + } + + $total = 0; + foreach ( $stats['data'] as $row ) { + if ( is_array( $row ) && isset( $row[ $index ] ) ) { + $total += (int) $row[ $index ]; + } + } + + return $total; + } + + /** + * Whether the site is relevant for the post-publish promo. + */ + public static function is_post_publish_promo_site_eligible(): bool { + $host = new Host(); + if ( $host->is_p2_site() ) { + return false; + } + + if ( get_user_option( self::POST_PUBLISH_PROMO_DISMISSED_OPTION, get_current_user_id() ) ) { + return false; + } + + return self::has_enough_recent_posts_for_post_publish_promo() && self::has_visitors_for_post_publish_promo(); + } + /** * Build the data bundle passed to the JS island via wp_localize_script. * diff --git a/projects/packages/podcast/src/class-podcast.php b/projects/packages/podcast/src/class-podcast.php index 5975f9ba9e2f..c60ca55ec389 100644 --- a/projects/packages/podcast/src/class-podcast.php +++ b/projects/packages/podcast/src/class-podcast.php @@ -52,6 +52,12 @@ public static function init() { // late-registered filter callback still takes effect. Podcast_Episode_Block::register_hooks(); + // Register the local REST routes before request-local rollout gates. + // Requests from public-api.wordpress.com may not satisfy those gates, + // but the wpcom/v2 routes still need to exist so permission and + // callback checks can handle the request. + Posts_To_Podcast_Endpoint::init(); + if ( ! self::is_enabled() ) { return; } @@ -76,20 +82,11 @@ public static function init() { } // Posts to Podcast lives behind its own filter so the Create AI - // Podcast page and its REST proxy can ship together but ramp - // independently of the broader untangle. Only register code in the - // context that actually needs it: the page in wp-admin, the REST - // routes during REST request processing. + // Podcast page can ship independently of the broader untangle. if ( self::is_posts_to_podcast_enabled() ) { if ( is_admin() ) { Create_AI_Podcast_Page::init(); } - // Register the local proxy in REST and admin contexts: the admin - // page bootstraps its initial quota + episodes via rest_do_request - // so the first paint doesn't pay the wpcom-proxy iframe round-trip. - if ( is_admin() || self::is_rest_request() ) { - Posts_To_Podcast_Endpoint::init(); - } } } @@ -112,31 +109,6 @@ public static function is_posts_to_podcast_enabled() { return (bool) apply_filters( 'jetpack_posts_to_podcast', self::is_proxied_request() ); } - /** - * Whether the current request is a WP REST API request. - * - * `Podcast::init()` typically fires before `REST_REQUEST` is defined - * and before `wp_is_serving_rest_request()` is reliable, so fall back - * to a URL prefix check. - */ - private static function is_rest_request() { - if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { - return true; - } - if ( function_exists( 'wp_is_serving_rest_request' ) && wp_is_serving_rest_request() ) { - return true; - } - if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { - return false; - } - $path = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); - if ( ! is_string( $path ) ) { - return false; - } - $prefix = function_exists( 'rest_get_url_prefix' ) ? rest_get_url_prefix() : 'wp-json'; - return false !== strpos( $path, '/' . trim( $prefix, '/' ) . '/' ); - } - /** * Whether the Podcast untangle is enabled for the current request. * diff --git a/projects/packages/podcast/src/class-posts-to-podcast-endpoint.php b/projects/packages/podcast/src/class-posts-to-podcast-endpoint.php index 8d3884c31f24..0037112791c5 100644 --- a/projects/packages/podcast/src/class-posts-to-podcast-endpoint.php +++ b/projects/packages/podcast/src/class-posts-to-podcast-endpoint.php @@ -21,27 +21,47 @@ */ class Posts_To_Podcast_Endpoint extends WP_REST_Controller { - const SUPPORTED_LENGTHS = array( 'short', 'medium', 'long' ); - const SUPPORTED_VOICE_PRESETS = array( 'witty', 'earnest', 'professional' ); + const SUPPORTED_LENGTHS = array( 'short', 'medium', 'long' ); + const SUPPORTED_VOICE_PRESETS = array( 'witty', 'earnest', 'professional' ); + const REST_NAMESPACE = 'wpcom/v2'; + const REST_BASE = 'posts-to-podcast'; + const POST_PUBLISH_PROMO_DISMISS_REST_ROUTE = 'post-publish-promo/dismiss'; /** - * Wire up routes if the feature is enabled for this site. + * Whether `init()` has wired its hooks. + * + * @var bool + */ + private static $initialized = false; + + /** + * Wire up routes. Idempotent. */ public static function init() { - if ( ! Posts_To_Podcast_Helper::is_enabled() ) { + if ( self::$initialized ) { return; } + self::$initialized = true; $instance = new self(); add_action( 'rest_api_init', array( $instance, 'register_routes' ) ); } /** - * Register the GET (feature info), POST (enqueue), and job-status routes. + * Get the REST API path used by apiFetch for post-publish promo dismissal. + * + * @return string + */ + public static function get_post_publish_promo_dismiss_rest_path() { + return '/' . self::REST_NAMESPACE . '/' . self::REST_BASE . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE; + } + + /** + * Register feature info, enqueue, job-status, and promo dismissal routes. */ public function register_routes() { - $this->namespace = 'wpcom/v2'; - $this->rest_base = 'posts-to-podcast'; + $this->namespace = self::REST_NAMESPACE; + $this->rest_base = self::REST_BASE; register_rest_route( $this->namespace, @@ -132,6 +152,18 @@ public function register_routes() { ), ) ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE, + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'dismiss_post_publish_promo' ), + 'permission_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); } /** @@ -205,6 +237,21 @@ public function read_episodes( WP_REST_Request $request ) { ); } + /** + * Persist post-publish promo dismissal for the current user and site. + * + * @return WP_REST_Response + */ + public function dismiss_post_publish_promo() { + update_user_option( get_current_user_id(), Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION, 1 ); + + return rest_ensure_response( + array( + 'dismissed' => true, + ) + ); + } + /** * Forward GET to the wpcom-side endpoint and return feature info * (remaining credits, plan, supported presets). diff --git a/projects/packages/podcast/src/editor/post-publish-podcast-promo/index.tsx b/projects/packages/podcast/src/editor/post-publish-podcast-promo/index.tsx new file mode 100644 index 000000000000..1b14b9c7742c --- /dev/null +++ b/projects/packages/podcast/src/editor/post-publish-podcast-promo/index.tsx @@ -0,0 +1,194 @@ +import apiFetch from '@wordpress/api-fetch'; +import { Button, Modal } from '@wordpress/components'; +import { usePrevious } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { getPlugin, registerPlugin } from '@wordpress/plugins'; + +import './style.scss'; + +type PromoData = { + createUrl: string; + dismissPath: string; +}; + +declare global { + interface Window { + jetpackPostPublishPodcastPromo?: PromoData; + } +} + +const getPromoData = (): PromoData | undefined => window.jetpackPostPublishPodcastPromo; + +// The editor can emit an immediate request-close from the publish click/focus transition. +const requestCloseGracePeriod = 1000; + +const PostPublishPodcastPromo = () => { + const data = getPromoData(); + const [ isOpen, setIsOpen ] = useState( false ); + const [ isClosedForSession, setIsClosedForSession ] = useState( false ); + const openedAt = useRef( 0 ); + + const { isPostPublished, isPublishingPost, postType } = useSelect( select => { + const editor = select( editorStore ); + return { + isPostPublished: editor.isCurrentPostPublished(), + isPublishingPost: editor.isPublishingPost(), + postType: editor.getCurrentPostType(), + }; + }, [] ); + const wasPublishingPost = usePrevious( isPublishingPost ); + const wasPostPublished = usePrevious( isPostPublished ); + + useEffect( () => { + if ( ! data || isClosedForSession ) { + return; + } + + if ( + postType === 'post' && + wasPublishingPost && + ! isPublishingPost && + ! wasPostPublished && + isPostPublished + ) { + window.setTimeout( () => { + openedAt.current = Date.now(); + setIsOpen( true ); + } ); + } + }, [ + data, + isClosedForSession, + isPostPublished, + isPublishingPost, + postType, + wasPostPublished, + wasPublishingPost, + ] ); + + const closeModal = useCallback( + ( force = false ) => { + if ( ! force && Date.now() - openedAt.current < requestCloseGracePeriod ) { + return; + } + + if ( data ) { + apiFetch( { path: data.dismissPath, method: 'POST' } ).catch( () => {} ); + } + setIsClosedForSession( true ); + setIsOpen( false ); + }, + [ data ] + ); + + const handleRequestClose = useCallback( () => closeModal(), [ closeModal ] ); + + const goToCreatePodcast = useCallback( () => { + if ( ! data ) { + return; + } + apiFetch( { path: data.dismissPath, method: 'POST' } ) + .catch( () => {} ) + .finally( () => { + ( window.top || window ).location.href = data.createUrl; + } ); + }, [ data ] ); + + if ( ! data || ! isOpen ) { + return null; + } + + return ( + +
+ + +
+
+

+ { __( 'Your post is live. Ready for the podcast version?', 'jetpack-podcast' ) } +

+

+ { __( + 'Give your audience another way to enjoy your content. Pick a date range or a few posts, and we’ll turn them into a two-host podcast episode.', + 'jetpack-podcast' + ) } +

+
+ +
+
+
+ ); +}; + +if ( ! getPlugin( 'jetpack-post-publish-podcast-promo' ) ) { + registerPlugin( 'jetpack-post-publish-podcast-promo', { + render: PostPublishPodcastPromo, + } ); +} diff --git a/projects/packages/podcast/src/editor/post-publish-podcast-promo/style.scss b/projects/packages/podcast/src/editor/post-publish-podcast-promo/style.scss new file mode 100644 index 000000000000..12a49e8ba6f7 --- /dev/null +++ b/projects/packages/podcast/src/editor/post-publish-podcast-promo/style.scss @@ -0,0 +1,146 @@ +.jetpack-post-publish-podcast-promo-modal { + max-width: 409px; + border-radius: 8px; + overflow: hidden; + + .components-modal__header { + position: absolute; + z-index: 1; + inset-inline-start: 0; + inset-inline-end: 0; + height: auto; + margin: 0; + padding: 8px; + border-bottom: 0; + background-color: transparent; + + button { + inset-inline-start: unset; + + svg { + fill: #fff; + } + } + } + + .components-modal__content { + padding: 0; + margin-block-start: 0; + + &::before { + margin: 0; + } + } +} + +.jetpack-post-publish-podcast-promo-modal__hero { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 44px 32px 36px; + background: + radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%), + linear-gradient(135deg, #1d3a8a 0%, #3858e9 48%, #7a37c8 100%); + overflow: hidden; +} + +.jetpack-post-publish-podcast-promo-modal__hero::before { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.18) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(circle at 50% 50%, #000 0%, transparent 72%); + pointer-events: none; +} + +.jetpack-post-publish-podcast-promo-modal__hero::after { + content: ""; + position: absolute; + inset-block-end: -88px; + inset-inline-end: -32px; + width: 220px; + height: 220px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.07); + pointer-events: none; +} + +.jetpack-post-publish-podcast-promo-modal__soundwave { + position: absolute; + z-index: 1; + inset-inline: 24px; + width: calc(100% - 48px); + height: 96px; + overflow: visible; + pointer-events: none; + + rect { + fill: rgba(255, 255, 255, 0.64); + } + + rect:nth-child(3n) { + fill: rgba(255, 255, 255, 0.38); + } +} + +.jetpack-post-publish-podcast-promo-modal__hero-art { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 100px; + height: 100px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.14); + backdrop-filter: blur(4px); +} + +.jetpack-post-publish-podcast-promo-modal__body { + padding: 24px 32px 32px; +} + +.jetpack-post-publish-podcast-promo-modal__title { + margin: 0; + color: #1e1e1e; + font-size: 20px; + font-weight: 600; + line-height: 1.3; +} + +.jetpack-post-publish-podcast-promo-modal__description { + color: #1e1e1e; + font-size: 13px; + line-height: 1.6; + margin: 12px 0 0; +} + +.jetpack-post-publish-podcast-promo-modal__actions { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; + margin-block-start: 24px; +} + +.jetpack-post-publish-podcast-promo-modal__primary-action.components-button { + justify-content: center; + height: auto; + padding: 12px 24px; + border-radius: 4px; + background-color: #3858e9; + font-size: 14px; + font-weight: 500; + + &:hover { + background-color: #2c48c7; + } + + &:focus { + box-shadow: 0 0 0 2px #3858e9; + } +} diff --git a/projects/packages/podcast/tests/php/Create_AI_Podcast_Page_Test.php b/projects/packages/podcast/tests/php/Create_AI_Podcast_Page_Test.php new file mode 100644 index 000000000000..588f4df5959e --- /dev/null +++ b/projects/packages/podcast/tests/php/Create_AI_Podcast_Page_Test.php @@ -0,0 +1,229 @@ +user_id ) { + delete_user_option( $this->user_id, Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION ); + } + wp_set_current_user( 0 ); + } + + /** + * Eligible sites need enough recent posts and weekly visitors. + */ + public function test_is_site_eligible_with_enough_recent_posts_and_weekly_visitors() { + $this->mock_recent_posts( 5 ); + $this->mock_weekly_visitors( array( 10, 10, 10, 10, 10 ) ); + + $this->assertTrue( Create_AI_Podcast_Page::is_post_publish_promo_site_eligible() ); + $this->assertTrue( $this->saw_last_month_date_query ); + $this->assertSame( + array( + 'unit' => 'day', + 'quantity' => 7, + 'stat_fields' => 'visitors', + ), + WPCOM_Stats::$last_args + ); + } + + /** + * Sites without enough weekly visitors are not eligible. + */ + public function test_is_site_not_eligible_without_enough_weekly_visitors() { + $this->mock_recent_posts( 5 ); + $this->mock_weekly_visitors( array( 10, 10, 10, 10, 9 ) ); + + $this->assertFalse( Create_AI_Podcast_Page::is_post_publish_promo_site_eligible() ); + } + + /** + * WordPress.com Simple sites use the local stats helper for weekly visitors. + */ + public function test_is_site_eligible_on_wpcom_simple_with_enough_weekly_visitors() { + Constants::set_constant( 'IS_WPCOM', true ); + $this->mock_recent_posts( 5 ); + $GLOBALS['podcast_promo_stats_get_visitors'] = array( 10, 10, 10, 10, 10 ); + + $this->assertTrue( Create_AI_Podcast_Page::is_post_publish_promo_site_eligible() ); + $this->assertSame( + array( get_current_blog_id(), gmdate( 'Y-m-d' ), 7, 1 ), + $GLOBALS['podcast_promo_stats_get_visitors_args'] + ); + $this->assertSame( array(), WPCOM_Stats::$last_args ); + } + + /** + * The draft being published counts toward the threshold. + */ + public function test_current_draft_counts_toward_published_post_threshold() { + $this->mock_recent_posts( 4 ); + $this->mock_weekly_visitors( array( 50 ) ); + + $GLOBALS['post'] = get_post( + wp_insert_post( + array( + 'post_title' => 'Draft post', + 'post_type' => 'post', + 'post_status' => 'draft', + ) + ) + ); + + $this->assertTrue( Create_AI_Podcast_Page::is_post_publish_promo_site_eligible() ); + } + + /** + * Already-published posts should not load the post-publish promo assets again. + */ + public function test_current_published_post_blocks_post_publish_promo_assets() { + $GLOBALS['post'] = get_post( + wp_insert_post( + array( + 'post_title' => 'Published post', + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ) + ); + + $method = new \ReflectionMethod( Create_AI_Podcast_Page::class, 'is_current_post_published_for_post_publish_promo' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $this->assertTrue( $method->invoke( null ) ); + } + + /** + * A user who has dismissed the promo is not eligible. + */ + public function test_is_site_not_eligible_after_user_dismisses_promo() { + $this->mock_recent_posts( 5 ); + $this->mock_weekly_visitors( array( 50 ) ); + $this->set_current_test_user(); + + $endpoint = new \Automattic\Jetpack\Podcast\Posts_To_Podcast_Endpoint(); + $endpoint->dismiss_post_publish_promo(); + + $this->assertFalse( Create_AI_Podcast_Page::is_post_publish_promo_site_eligible() ); + } + + /** + * Mock the recent post query. + * + * @param int $count Number of posts to return. + */ + private function mock_recent_posts( $count ) { + $this->recent_post_count = $count; + $this->saw_last_month_date_query = false; + add_filter( 'posts_pre_query', array( $this, 'mock_recent_posts_query' ), 10, 2 ); + } + + /** + * Short-circuit the recent posts query. + * + * @param array|null $posts Query results. + * @param \WP_Query $query Query object. + * @return array + */ + public function mock_recent_posts_query( $posts, $query ) { + unset( $posts ); + + $date_query = $query->query_vars['date_query'] ?? array(); + $this->saw_last_month_date_query = isset( $date_query[0]['after'] ) + && '1 month ago' === $date_query[0]['after'] + && ! empty( $date_query[0]['inclusive'] ); + + return array_fill( 0, $this->recent_post_count, 1 ); + } + + /** + * Mock weekly visitors. + * + * @param int[] $visitors Visitor counts. + */ + private function mock_weekly_visitors( array $visitors ) { + WPCOM_Stats::$visits = array( + 'fields' => array( 'period', 'visitors' ), + 'data' => array_map( + function ( $count ) { + return array( '2026-05-18', $count ); + }, + $visitors + ), + ); + } + + /** + * Set a current test user. + */ + private function set_current_test_user() { + $this->user_id = wp_insert_user( + array( + 'user_login' => 'podcast-promo-test-user', + 'user_pass' => 'password', + 'user_email' => 'podcast-promo-test-user@example.com', + ) + ); + + wp_set_current_user( $this->user_id ); + } +} diff --git a/projects/packages/podcast/tests/php/mocks/class-wpcom-stats.php b/projects/packages/podcast/tests/php/mocks/class-wpcom-stats.php new file mode 100644 index 000000000000..b7c1c9d608f8 --- /dev/null +++ b/projects/packages/podcast/tests/php/mocks/class-wpcom-stats.php @@ -0,0 +1,40 @@ +