Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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 includes/content-gate/class-content-gate.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public static function init() {
include __DIR__ . '/class-content-restriction-control.php';
include __DIR__ . '/class-block-patterns.php';
include __DIR__ . '/class-metering.php';
include __DIR__ . '/class-metering-countdown.php';
include __DIR__ . '/content-gifting/class-content-gifting.php';
}

Expand Down
249 changes: 249 additions & 0 deletions includes/content-gate/class-metering-countdown.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
<?php
/**
* WooCommerce Content Gate Metering.
*
* @package Newspack
*/

namespace Newspack;

/**
* WooCommerce Content Gate Metering class.
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file header and class docblock describe this as "WooCommerce Content Gate Metering" but this class is actually for the countdown banner feature, not WooCommerce metering. The documentation should accurately describe the class purpose as "Content Gate Metering Countdown Banner" or similar.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in dbb794b

*/
class Metering_Countdown {

const OPTION_PREFIX = 'np_countdown_banner_';

/**
* Initialize hooks.
*/
public static function init() {
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] );
add_action( 'wp_footer', [ __CLASS__, 'print_cta' ] );
add_filter( 'body_class', [ __CLASS__, 'filter_body_class' ] );
add_filter( 'newspack_ads_placement_data', [ __CLASS__, 'filter_ads_placement_data' ], 10, 2 );
}

/**
* Get all settings with default values for the countdown banner.
*
* @return array Default countdown settings.
*/
public static function get_default_settings() {
return [
'enabled' => false,
'style' => 'light',
'cta_label' => __( 'Subscribe now and get unlimited access.', 'newspack' ),
'button_label' => __( 'Subscribe now', 'newspack' ),
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent text domain. Lines 36-37 use 'newspack' as the text domain, but lines 192 and 207 use 'newspack-plugin'. Based on the rest of the codebase (including other files in the same directory), the correct text domain should be 'newspack-plugin'.

Suggested change
'cta_label' => __( 'Subscribe now and get unlimited access.', 'newspack' ),
'button_label' => __( 'Subscribe now', 'newspack' ),
'cta_label' => __( 'Subscribe now and get unlimited access.', 'newspack-plugin' ),
'button_label' => __( 'Subscribe now', 'newspack-plugin' ),

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dbb794b

'cta_url' => '',
];
}

/**
* Get all settings for the countdown banner.
*
* @param string $key Optional key to get a specific setting. If not provided, all settings will be returned.
*
* @return array Countdown settings.
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @return documentation states it returns an array but doesn't specify what type of value it could return when a $key parameter is provided. When $key is provided, it returns a single value (mixed type) from get_option(), not an array. The documentation should reflect both return types: @return array|mixed Array of all settings, or a single setting value if $key is provided.

Suggested change
* @return array Countdown settings.
* @return array|mixed Array of all settings, or a single setting value if $key is provided.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a strong opinion about this one

Copy link
Contributor Author

@dkoo dkoo Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the docblock in dbb794b

*/
public static function get_settings( $key = null ) {
$settings = self::get_default_settings();
if ( $key && isset( $settings[ $key ] ) ) {
return get_option( self::OPTION_PREFIX . $key, $settings[ $key ] );
}
foreach ( $settings as $key => $value ) {
$settings[ $key ] = get_option( self::OPTION_PREFIX . $key, $value );
}
return $settings;
}

/**
* Update settings for the countdown banner.
*
* @param array $settings New countdown settings.
*
* @return array|\WP_Error Updated countdown settings or error if update fails.
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @return documentation states it returns array|\WP_Error but the function never returns a \WP_Error. It always returns the $current_settings array. Either the documentation should be updated to only indicate @return array, or error handling should be implemented to actually return a \WP_Error when appropriate.

Suggested change
* @return array|\WP_Error Updated countdown settings or error if update fails.
* @return array Updated countdown settings.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

@dkoo dkoo Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dbb794b to actually return an error if there is one

*/
public static function update_settings( $settings ) {
$default_settings = self::get_default_settings();
$current_settings = self::get_settings();
foreach ( $settings as $key => $value ) {
if ( isset( $current_settings[ $key ] ) ) {
if ( $key === 'style' && ! in_array( $value, [ 'light', 'dark' ], true ) ) {
continue;
}
if ( empty( $value ) ) {
delete_option( self::OPTION_PREFIX . $key );
$current_settings[ $key ] = $default_settings[ $key ];
continue;
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enabled setting should be handled specially like the style setting. Using empty( $value ) on line 75 will treat boolean false as empty, which means if someone tries to disable the banner by setting enabled to false, it will instead reset to the default value (also false), but the option won't be deleted. For boolean values, explicit type checking should be used instead of empty(). Consider:

if ( $key === 'enabled' ) {
    update_option( self::OPTION_PREFIX . $key, (bool) $value );
    $current_settings[ $key ] = (bool) $value;
    continue;
}

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can cause issues in the future, but I'm not a fan of the proposed approach. We could either enforce the $settings to always include the entire schema and do ! isset() to delete, or skip this entirely and not handle deletion in this method. If needed, a separate delete_setting() could be implemented eventually.

Copy link
Contributor Author

@dkoo dkoo Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, in dbb794b I removed the delete_option from this for now.

Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input sanitization. The update_settings() method receives unsanitized user input from the REST API and stores it directly using update_option(). While the style field has validation (lines 72-74), the cta_label, button_label, and cta_url fields are stored without sanitization. These should be sanitized before storing:

if ( $key === 'cta_label' || $key === 'button_label' || $key === 'cta_url' ) {
    $value = sanitize_text_field( $value );
}

Note that similar fields in the content gifting feature are properly sanitized (see lines 649, 652, 655 in the wizard file).

Suggested change
}
}
if ( $key === 'cta_label' || $key === 'button_label' || $key === 'cta_url' ) {
$value = sanitize_text_field( $value );
}

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be implemented as a separate sanitizer function.

Copy link
Contributor Author

@dkoo dkoo Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dbb794b implements a separate sanitize_setting method which should handle this as well as sanitize user input before saving to the DB.

update_option( self::OPTION_PREFIX . $key, $value );
$current_settings[ $key ] = $value;
}
}
return $current_settings;
}

/**
* Whether the countdown banner should be displayed.
*
* @return bool
*/
public static function is_enabled() {
return self::get_settings( 'enabled' ) && Metering::is_metering() && is_singular();
}

/**
* Enqueue assets.
*/
public static function enqueue_assets() {
// Enqueue assets only if enabled and the post is metered.
if ( ! self::is_enabled() ) {
return;
}
$asset = require_once dirname( NEWSPACK_PLUGIN_FILE ) . '/dist/content-banner.asset.php';

// Ensure the content gate metering script is enqueued first.
$asset['dependencies'][] = 'newspack-content-gate-metering';
wp_enqueue_script( 'newspack-content-banner', Newspack::plugin_url() . '/dist/content-banner.js', $asset['dependencies'], NEWSPACK_PLUGIN_VERSION, true );
wp_enqueue_style( 'newspack-content-banner', Newspack::plugin_url() . '/dist/content-banner.css', [], NEWSPACK_PLUGIN_VERSION );
}

/**
* Print the subscribe button.
*/
public static function print_subscribe_button() {
if ( ! class_exists( 'Newspack_Blocks' ) || ! class_exists( 'Newspack_Blocks\Modal_Checkout' ) || ! class_exists( 'Newspack_Blocks\Modal_Checkout\Checkout_Data' ) || ! function_exists( 'wc_get_product' ) ) {
return;
}
$settings = self::get_settings();
$button_label = $settings['button_label'];
$button_class = 'dark' === $settings['style'] ? 'newspack-ui__button--primary-light' : 'newspack-ui__button--accent';

$cta_url = $settings['cta_url'];
if ( $cta_url ) {
?>
<a href="<?php echo esc_url( $cta_url ); ?>" class="newspack-ui__button newspack-ui__button--x-small <?php echo esc_attr( $button_class ); ?>"><?php echo esc_html( $button_label ); ?></a>
<?php
return;
}

// If CTA url is not provided, try a modal checkout using the primary subscription tier product.
$product = Subscriptions_Tiers::get_primary_subscription_tier_product();
if ( ! $product ) {
return;
}
\Newspack_Blocks\Modal_Checkout::enqueue_modal( $product->get_id() );
\Newspack_Blocks::enqueue_view_assets( 'checkout-button' );
$checkout_data = \Newspack_Blocks\Modal_Checkout\Checkout_Data::get_checkout_data( $product );
?>
<div class="wp-block-newspack-blocks-checkout-button">
<form data-checkout="<?php echo esc_attr( wp_json_encode( $checkout_data ) ); ?>" target="newspack_modal_checkout_iframe">
<input type="hidden" name="newspack_checkout" value="1" />
<input type="hidden" name="modal_checkout" value="1" />
<input type="hidden" name="product_id" value="<?php echo esc_attr( $product->get_id() ); ?>" />
<button type="submit" class="newspack-ui__button newspack-ui__button--x-small <?php echo esc_attr( $button_class ); ?>"><?php echo esc_html( $button_label ); ?></button>
</form>
</div>
<?php
}

/**
* Print the countdown banner.
*/
public static function print_cta() {
if ( ! self::is_enabled() ) {
return;
}
$settings = self::get_settings();
$style_class = sprintf( 'is-style-%s', $settings['style'] );
$classes = [ $style_class ];
$total_views = Metering::get_total_metered_views( \is_user_logged_in() );
if ( false === $total_views ) {
return '';
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent return type. The function is declared to return void (no return type specified), but on line 163 it returns an empty string return ''. This should either return nothing or the function signature should document what it returns.

Suggested change
return '';
return;

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: @return void in the docblock

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in dbb794b—added @return void in the docblock and made sure to return nothing instead of an empty string.

}
$views = Metering::get_current_user_metered_views();
if ( $views === 0 || Metering::is_frontend_metering() ) {
$classes[] = 'newspack-countdown-banner__cta--hidden';
}
?>
<div class="newspack-ui">
<div class="banner newspack-countdown-banner__cta <?php echo esc_attr( implode( ' ', $classes ) ); ?>">
<div class="wrapper newspack-countdown-banner__cta__content">
<div class="newspack-countdown-banner__cta__content__wrapper">
<span class="newspack-countdown-banner__cta__content__countdown newspack-ui__font--s">
<strong>
<?php
echo wp_kses_post(
/**
* Filter the countdown message that shows how many metered articles the user has viewed.
* Sanitized via wp_kses_post, so basic HTML is allowed.
*
* @param string $message The countdown message HTML string.
* @param int $views The current number of metered views.
* @param int $total_views The total number of allowed views per period.
* @param string $metering_period The metering period.
* @return string The filtered countdown message HTML string.
*/
apply_filters(
'newspack_countdown_banner_countdown_message',
sprintf(
/* translators: 1: current number of metered views, 2: total metered views, 3: the metering period. */
__( '<span class="newspack-countdown-banner__views">%1$d</span>/<span class="newspack-countdown-banner__total_views">%2$d</span> free articles this %3$s', 'newspack-plugin' ),
$views,
$total_views,
Metering::get_metering_period()
),
$views,
$total_views,
Metering::get_metering_period()
)
);
?>
</strong>
</span>
<span class="newspack-countdown-banner__cta__content__message newspack-ui__font--xs">
<?php echo esc_html( $settings['cta_label'] ); ?>
<a href="#signin_modal"><?php echo esc_html( __( 'Sign in to an existing account', 'newspack-plugin' ) ); ?></a>.
</span>
Comment on lines +234 to +243
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to what was implemented for content gifting, I expected this link to be "Create an account" (#register_modal) if there's registration metering.

Copy link
Contributor Author

@dkoo dkoo Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for the Countdown banner, the purpose of the countdown CTA is a bit different. It's to highlight that even though you have n "free" views remaining, you can skip ahead to get unlimited access by just purchasing a membership at anytime. The "sign in" link is for users who might already have an active subscription but just aren't logged in yet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if they're not logged in, they may get more content by registering. It's a lead opportunity for readers who are not willing to subscribe.

I also just noticed that this should be behind a is_user_logged_in() condiitonal

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some discussion, added some additional logic:

  • If the user is NOT logged in and metering settings allow for free views for registered users, show a "Create account" link to allow the user to get additional metered views
  • If the user is NOT logged in and metering settings do NOT allow for free registered views, show a "Sign in" link for users who might already have an account with a subscription
  • If the user is logged in, show nothing

</div>
<?php self::print_subscribe_button(); ?>
</div>
</div>
</div>
<?php
}

/**
* Filter the body class.
*
* @param array $classes The body classes.
*
* @return array The filtered body classes.
*/
public static function filter_body_class( $classes ) {
if ( self::is_enabled() ) {
$classes[] = 'newspack-has-countdown-banner';
}
return $classes;
}

/**
* Disable the sticky footer ad placement when rendering the countdown banner.
*
* @param array $data The ads placement data.
* @param string $placement_key The placement key.
*
* @return array The filtered ads placement data.
*/
public static function filter_ads_placement_data( $data, $placement_key ) {
if ( ! self::is_enabled() ) {
return $data;
}
if ( $placement_key === 'sticky' ) {
$data['enabled'] = false;
}
return $data;
}
}
Metering_Countdown::init();
5 changes: 5 additions & 0 deletions includes/content-gate/class-metering.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ public static function is_logged_in_metering_allowed( $post_id = null ) {
return false;
}

// Not in checkout modals.
if ( method_exists( 'Newspack_Blocks\Modal_Checkout', 'is_modal_checkout' ) && \Newspack_Blocks\Modal_Checkout::is_modal_checkout() ) {
return false;
}

$gate_post_id = Content_Gate::get_gate_post_id();
$metering = \get_post_meta( $gate_post_id, 'metering', true );
$priority = \get_post_meta( $gate_post_id, 'gate_priority', true );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,13 +445,13 @@ public static function enqueue_assets() {
if ( ! self::can_gift_post() && ! self::is_gifted_post() && ! isset( $_GET[ self::QUERY_ARG ] ) && ! is_admin() && ! is_customize_preview() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
wp_enqueue_style( 'newspack-content-gifting', Newspack::plugin_url() . '/dist/content-gifting.css', [], NEWSPACK_PLUGIN_VERSION );
wp_enqueue_style( 'newspack-content-banner', Newspack::plugin_url() . '/dist/content-banner.css', [], NEWSPACK_PLUGIN_VERSION );

if ( is_singular() ) {
$asset = require_once dirname( NEWSPACK_PLUGIN_FILE ) . '/dist/content-gifting.asset.php';
wp_enqueue_script( 'newspack-content-gifting', Newspack::plugin_url() . '/dist/content-gifting.js', $asset['dependencies'], NEWSPACK_PLUGIN_VERSION, true );
$asset = require_once dirname( NEWSPACK_PLUGIN_FILE ) . '/dist/content-banner.asset.php';
wp_enqueue_script( 'newspack-content-banner', Newspack::plugin_url() . '/dist/content-banner.js', $asset['dependencies'], NEWSPACK_PLUGIN_VERSION, true );
wp_localize_script(
'newspack-content-gifting',
'newspack-content-banner',
'newspack_content_gifting',
[
'ajax_url' => add_query_arg(
Expand Down
4 changes: 4 additions & 0 deletions includes/wizards/audience/class-audience-wizard.php
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,9 @@ public function api_update_content_gating_settings( $request ) {
if ( isset( $args['show_on_subscription_tab'] ) ) {
Memberships::set_show_on_subscription_tab_setting( (bool) $args['show_on_subscription_tab'] );
}
if ( isset( $args['countdown_banner'] ) ) {
Metering_Countdown::update_settings( $args['countdown_banner'] );
}
if ( isset( $args['content_gifting'] ) ) {
if ( isset( $args['content_gifting']['enabled'] ) ) {
Content_Gifting::set_enabled( (bool) $args['content_gifting']['enabled'] );
Expand Down Expand Up @@ -913,6 +916,7 @@ private static function get_memberships_settings() {
'plans' => Memberships::get_plans(),
'require_all_plans' => Memberships::get_require_all_plans_setting(),
'show_on_subscription_tab' => Memberships::get_show_on_subscription_tab_setting(),
'countdown_banner' => Metering_Countdown::get_settings(),
'content_gifting' => Content_Gifting::get_settings(),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@
import domReady from '@wordpress/dom-ready';
import { queuePageReload } from '../reader-activation/utils';

import './content-gifting.scss';
import './content-banner.scss';

const settings = window.newspack_metering_settings || {};
const storeKey = 'metering-' + settings.gate_id || 0;

window.newspackRAS = window.newspackRAS || [];

domReady( () => {
const cta = document.querySelector( '.newspack-content-gifting__cta,.newspack-countdown-banner__cta' );
const setBodyOffset = () => {
const cta = document.querySelector( '.newspack-content-gifting__cta' );
if ( ! cta || ! document.body.classList.contains( 'newspack-is-gifted-post' ) ) {
if (
! cta ||
( ! document.body.classList.contains( 'newspack-is-gifted-post' ) &&
! document.body.classList.contains( 'newspack-has-countdown-banner' ) )
) {
return;
}

const updateOffset = () => {
document.body.style.setProperty( '--newspack-content-gifting-cta-offset', `${ cta.offsetHeight }px` );
document.body.style.setProperty( '--newspack-content-banner-cta-offset', `${ cta.offsetHeight }px` );
};

updateOffset();
Expand All @@ -29,6 +36,22 @@ domReady( () => {

setBodyOffset();

// Countdown banner.
window.newspackRAS?.push( ras => {
const views = document.querySelector( '.newspack-countdown-banner__views' );
if ( ! views || '0' !== views.textContent ) {
return;
}
const data = ras?.store?.get( storeKey ) || {
content: [],
};
if ( data.content.length > 0 ) {
views.textContent = data.content.length;
cta.classList.remove( 'newspack-countdown-banner__cta--hidden' );
}
} );

// Content gifting modal.
const modal = document.getElementById( 'newspack-content-gifting-modal' );
if ( ! modal ) {
return;
Expand Down
Loading