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
101 changes: 88 additions & 13 deletions assets/js/paybutton.js

Large diffs are not rendered by default.

73 changes: 60 additions & 13 deletions assets/js/paywalled-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,65 @@ jQuery(document).ready(function($) {
tx_amount: tx.amount || '',
tx_timestamp: tx.timestamp || '',
// NEW: Pass the first input address to store in the DB even for non-logged-in users
user_address: (tx.inputAddresses && tx.inputAddresses.length > 0) ? tx.inputAddresses[0] : ''
user_address: (tx.inputAddresses && tx.inputAddresses.length > 0) ? tx.inputAddresses[0] : '',
autoClose: configData.autoClose
},
success: function() {
setTimeout(function() {
// Get the base URL (without any query parameters or hash)
var baseUrl = location.href.split('#')[0].split('?')[0];
// Build a new URL that includes a timestamp parameter to bust caches
var newUrl = baseUrl + '?t=' + Date.now() + '#unlocked';
// Update the URL in the address bar without triggering a navigation
window.history.replaceState(null, '', newUrl);
// Force a reload
location.reload();
}, 2000);
success: function () {
setTimeout(function () {
jQuery.ajax({
method: 'POST',
url: PaywallAjax.ajaxUrl,
data: {
action: 'fetch_unlocked_content',
post_id: configData.postId,
security: PaywallAjax.nonce
},
success: function (resp) {
if (resp && resp.success) {
// 1) Replace only the paywalled block content
var $wrapper = jQuery('#pb-paywall-' + configData.postId);
if ($wrapper.length && resp.data.unlocked_html) {
$wrapper.html(resp.data.unlocked_html);
}

// 2) Replace the placeholder where the WP theme wants comments
if (resp.data.comments_html) {
// 1) Prefer our placeholder (exact spot theme expects)
var $slot = jQuery('#paybutton-comments-placeholder');
if ($slot.length) {
$slot.replaceWith(resp.data.comments_html);
} else {
// 2) Fallback: replace a visible comments container if present
var $comments = jQuery('#comments, .comments-area').first();
if ($comments.length) {
$comments.replaceWith(resp.data.comments_html);
} else if ($wrapper.length) {
// 3) Last resort: append just after the content wrapper
$wrapper.after(resp.data.comments_html);
}
}
}
// 3) Re-init threaded replies if enabled and available
if (typeof addComment !== 'undefined' && addComment && typeof addComment.init === 'function') {
addComment.init();
}

// Optional scroll-to-unlocked-content-indicator + Cache Busting Mechanism
var baseUrl = location.href.split('#')[0].split('?')[0];
var newUrl = baseUrl + '?t=' + Date.now() + '#unlocked';
window.history.replaceState(null, '', newUrl);

if (PaywallAjax.scrollToUnlocked === '1' || PaywallAjax.scrollToUnlocked === 1) {
var $target = jQuery('#unlocked');
if ($target.length) {
var headerOffset = 80;
jQuery('html, body').animate({ scrollTop: $target.offset().top - headerOffset }, 500);
}
}
}
}
});
}, 20); // Slight delay to ensure DB/cookie update is processed before fetching content
}
});
};
Expand All @@ -69,7 +115,8 @@ jQuery(document).ready(function($) {
successText: configData.successText,
onSuccess: configData.onSuccess,
theme: configData.theme,
opReturn: configData.opReturn //This is a hack to give the PB server the post ID to send it back to WP's DB
opReturn: configData.opReturn, //This is a hack to give the PB server the post ID to send it back to WP's DB
autoClose: configData.autoClose
});
});
});
Expand Down
149 changes: 148 additions & 1 deletion includes/class-paybutton-ajax.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public function __construct() {

add_action( 'wp_ajax_payment_trigger', array( $this, 'payment_trigger' ) );
add_action( 'wp_ajax_nopriv_payment_trigger', array( $this, 'payment_trigger' ) );

// Function that improves UX by fetching unlocked content without reloading page
add_action( 'wp_ajax_fetch_unlocked_content', array( $this, 'fetch_unlocked_content' ) );
add_action( 'wp_ajax_nopriv_fetch_unlocked_content', array( $this, 'fetch_unlocked_content' ) );

}
/**
* Payment Trigger Handler with Cryptographic Verification
Expand Down Expand Up @@ -207,7 +212,7 @@ public function logout() {

/**
* Marks a payment as successful and unlocks content.
*/
*/
public function mark_payment_successful() {
check_ajax_referer( 'paybutton_paywall_nonce', 'security' );

Expand Down Expand Up @@ -255,6 +260,148 @@ public function mark_payment_successful() {
wp_send_json_success();
}

/**
* Fetches the unlocked content and comments for a post via AJAX to display on the front-end without reloading the page.
*
* This function verifies the AJAX nonce for security,
* checks if the current user has unlocked the specified post,
* and if so, retrieves and returns the inner content of the [paywalled_content] shortcode
* along with the comments template HTML.
*/
public function fetch_unlocked_content() {
check_ajax_referer( 'paybutton_paywall_nonce', 'security' );

$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
if ( ! $post_id ) {
wp_send_json_error( array( 'message' => 'Missing post_id' ), 400 );
}

if ( ! $this->is_unlocked_for_current_user( $post_id ) ) {
wp_send_json_error( array( 'message' => 'Not unlocked' ), 403 );
}

$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status ) {
wp_send_json_error( array( 'message' => 'Invalid post' ), 404 );
}

// Extract inner [paywalled_content]...[/paywalled_content] shortcode content
$inner = $this->extract_shortcode_inner_content( $post->post_content );

// Include the optional unlocked content indicator if enabled
$indicator = '';
if ( get_option('paybutton_scroll_to_unlocked', '0') === '1' ) {
$indicator = '<div id="unlocked" class="unlocked-indicator"><span>Unlocked Content Below</span></div>';
}

// Render the inner content like the shortcode does
$GLOBALS['post'] = $post;
setup_postdata( $post );

// Some filters check in_the_loop(), so temporarily set it
global $wp_query;
$__prev_in_loop = isset( $wp_query ) ? $wp_query->in_the_loop : null;
if ( isset( $wp_query ) ) {
$wp_query->in_the_loop = true;
}

// Run the full post-content pipeline (blocks, shortcodes, embeds, autop, etc.) filter
$body = apply_filters( 'the_content', $inner );

// Restore the flag
if ( isset( $wp_query ) ) {
$wp_query->in_the_loop = $__prev_in_loop;
}

$unlocked_html = $indicator . $body;

// Render the theme’s comments template and capture its HTML
$GLOBALS['post'] = $post;
setup_postdata( $post );

// TEMP filters to bypass paywall comment blockers on AJAX
$force_open = function( $open, $pid ) use ( $post ) {
return ( intval($pid) === intval($post->ID) ) ? true : $open;
};
add_filter( 'comments_open', $force_open, 10000, 2 );

$unblock_query = function( $query ) use ( $post ) {
// Only touch the query for this exact post
if ( isset( $query->query_vars['post_id'] ) && intval( $query->query_vars['post_id'] ) === intval( $post->ID ) ) {
// If another filter hid comments with comment__in => array(0), undo it
if ( isset( $query->query_vars['comment__in'] ) && $query->query_vars['comment__in'] === array(0) ) {
unset( $query->query_vars['comment__in'] );
}
// Ensure approved comments are queried normally
$query->query_vars['status'] = 'approve';
}
};
add_action( 'pre_get_comments', $unblock_query, 10000 );

// Some WP themes rely on this being set when rendering comments out of loop contexts
global $withcomments;
$withcomments = true;

ob_start();
comments_template();
$comments_html = ob_get_clean();

// Remove temp filters and clean up
remove_filter( 'comments_open', $force_open, 10000 );
remove_action( 'pre_get_comments', $unblock_query, 10000 );
wp_reset_postdata();

wp_send_json_success( array(
'unlocked_html' => $unlocked_html,
'comments_html' => $comments_html,
) );
}

/**
* Checks if the current user has unlocked the specified post.
*
* This function first checks if the post ID is present in the cookie-based
* unlock state. If not found, it then checks the database for an unlock record
* associated with the user's wallet address (if available).
*
* @param int $post_id The ID of the post to check.
* @return bool True if the post is unlocked for the current user, false otherwise.
*/
private function is_unlocked_for_current_user( $post_id ) {
// Cookie-based (set by mark_payment_successful)
$articles = PayButton_State::get_articles();
if ( isset( $articles[ $post_id ] ) ) {
return true;
}
// Also allow DB-based unlock if the user has a wallet "login"
$addr = PayButton_State::get_address();
if ( $addr ) {
global $wpdb;
$table = $wpdb->prefix . 'paybutton_paywall_unlocked';
$found = $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM $table WHERE pb_paywall_user_wallet_address = %s AND post_id = %d LIMIT 1",
sanitize_text_field( $addr ),
$post_id
) );
if ( $found ) return true;
}
return false;
}

/**
* Extracts the inner content of the [paywalled_content] shortcode from the post content.
*
* @param string $post_content The full post content.
* @return string The inner content of the shortcode, or an empty string if not found.
*/
private function extract_shortcode_inner_content( $post_content ) {
$inner = '';
if ( preg_match( '/\\[paywalled_content[^\\]]*\\](.*?)\\[\\/paywalled_content\\]/is', $post_content, $m ) ) {
$inner = $m[1];
}
return $inner;
}

/**
* Store the unlock information in the database.
*/
Expand Down
52 changes: 43 additions & 9 deletions includes/class-paybutton-public.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public function __construct() {
add_filter( 'comments_open', array( $this, 'filter_comments_open' ), 999, 2 );
add_action( 'pre_get_comments', array( $this, 'filter_comments_query' ), 999 );
add_shortcode( 'paybutton', [ $this, 'paybutton_generator_shortcode' ] );
// Add a filter to replace the comments template with our custom one in order to match the user's theme
add_filter( 'comments_template', array( $this, 'paybutton_use_comments_placeholder' ), 999 );
}

/**
Expand Down Expand Up @@ -97,6 +99,11 @@ public function enqueue_public_assets() {
true
);

// Load the comment reply script
if ( is_singular() && get_option( 'thread_comments' ) ) {
wp_enqueue_script( 'comment-reply' );
}

/**
* Localizes the 'paybutton-cashtab-login' script with variables needed for AJAX interactions.
*
Expand Down Expand Up @@ -224,7 +231,8 @@ public function paybutton_paywall_shortcode( $atts, $content = null ) {
'tertiary' => $color_tertiary,
),
),
'opReturn' => (string) $post_id //This is a hack to give the PB server the post ID to send it back to WP's DB
'opReturn' => (string) $post_id, //This is a hack to give the PB server the post ID to send it back to WP's DB
'autoClose' => true
);

//NEW: If the admin enabled “Show Unlock Count on Front‐end,” and this post is NOT yet unlocked then display unlock count on the front end.
Expand Down Expand Up @@ -255,13 +263,13 @@ public function paybutton_paywall_shortcode( $atts, $content = null ) {

ob_start(); //When ob_start() is called, PHP begins buffering all subsequent output instead of printing it to the browser.
?>

<?php
//Print the unlock‐count HTML (if enabled) before the PayButton container.
echo $unlock_label_html;
?>

<div id="paybutton-container-<?php echo esc_attr( $post_id ); ?>" class="paybutton-container" data-config="<?php echo esc_attr( json_encode( $config ) ); ?>" style="text-align: center;"></div>
<div id="pb-paywall-<?php echo esc_attr( $post_id ); ?>" class="pb-paywall">
<?php echo wp_kses_post($unlock_label_html) ?>
<div id="paybutton-container-<?php echo esc_attr( $post_id ); ?>"
class="paybutton-container"
data-config="<?php echo esc_attr( json_encode( $config ) ); ?>"
style="text-align: center;"></div>
</div>
<?php
return ob_get_clean(); // ob_get_clean() Returns the HTML string to WordPress so it is inserted properly.
}
Expand Down Expand Up @@ -339,7 +347,7 @@ public function filter_comments_open( $open, $post_id ) {
if ( $this->post_is_unlocked( $post_id ) ) {
return $open;
}
return false;
return true;
}

/**
Expand All @@ -363,4 +371,30 @@ public function filter_comments_query( $comment_query ) {
$comment_query->query_vars['comment__in'] = array(0);
}
}

/**
* Use a custom comments template if the post is paywalled.
*
* @param string $template The path to the comments template.
* @return string The modified template path.
*/
public function paybutton_use_comments_placeholder( $template ) {
if ( ! is_singular() || is_admin() ) {
return $template;
}
global $post;
if ( ! $post ) {
return $template;
}

// If this post is NOT unlocked in the cookie, use our placeholder template.
$is_unlocked = $this->post_is_unlocked( $post->ID );
if ( ! $is_unlocked ) {
$placeholder = plugin_dir_path( dirname( __FILE__ ) ) . 'templates/public/comments-placeholder.php';
if ( file_exists( $placeholder ) ) {
return $placeholder;
}
}
return $template;
}
}
14 changes: 14 additions & 0 deletions templates/public/comments-placeholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- File: templates/public/comments-placeholder.php -->
<?php
/**
* Placeholder that keeps theme layout where comments_template() is called.
* We replace this node via AJAX after payment.
*/
if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly

$post_id = get_the_ID();
?>
<div id="paybutton-comments-placeholder"
class="comments-area"
data-post-id="<?php echo esc_attr( $post_id ); ?>">
</div>